diff --git a/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs
new file mode 100644
index 0000000..8f004ad
--- /dev/null
+++ b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs
@@ -0,0 +1,207 @@
+using Metin2Launcher.Apply;
+using Metin2Launcher.Config;
+using Metin2Launcher.Logging;
+using Metin2Launcher.Manifest;
+using Metin2Launcher.Transfer;
+
+namespace Metin2Launcher.Orchestration;
+
+public enum LauncherState
+{
+ Idle,
+ FetchingManifest,
+ VerifyingSignature,
+ Diffing,
+ Downloading,
+ Applying,
+ UpToDate,
+ OfflineFallback,
+ SignatureFailed,
+ Launching,
+}
+
+public sealed class UpdateProgress
+{
+ public LauncherState State { get; init; }
+ public double? Percent { get; init; } // 0..100, null = indeterminate
+ public string? Detail { get; init; } // current file / target path
+ public LoadedManifest? Manifest { get; init; }
+}
+
+///
+/// Owns the update flow extracted from the original CLI Program.cs. Drives an
+/// sink instead of writing to the console.
+/// All logic — manifest fetch, signature verify, diff, download, apply — is preserved.
+///
+public sealed class UpdateOrchestrator
+{
+ public sealed record Result(bool Success, LauncherState FinalState, LoadedManifest? Manifest);
+
+ private readonly string _clientRoot;
+ private readonly string _stagingDir;
+ private readonly LauncherSettings _settings;
+ private readonly HttpClient _http;
+
+ public UpdateOrchestrator(string clientRoot, LauncherSettings settings, HttpClient http)
+ {
+ _clientRoot = clientRoot;
+ _stagingDir = Path.Combine(clientRoot, LauncherConfig.StateDirName, LauncherConfig.StagingDirName);
+ _settings = settings;
+ _http = http;
+ }
+
+ public async Task RunAsync(IProgress progress, CancellationToken ct)
+ {
+ LoadedManifest? loaded = null;
+
+ // 1. fetch manifest
+ progress.Report(new UpdateProgress { State = LauncherState.FetchingManifest });
+ var loader = new ManifestLoader(_http);
+ try
+ {
+ using var fetchCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ fetchCts.CancelAfter(LauncherConfig.ManifestFetchTimeout);
+ loaded = await loader.FetchAsync(
+ _settings.EffectiveManifestUrl(),
+ _settings.EffectiveSignatureUrl(),
+ fetchCts.Token).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("manifest fetch failed: " + ex.Message);
+ progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100 });
+ return new Result(false, LauncherState.OfflineFallback, null);
+ }
+
+ // 2. verify signature
+ progress.Report(new UpdateProgress { State = LauncherState.VerifyingSignature, Manifest = loaded });
+ if (!SignatureVerifier.Verify(loaded.RawBytes, loaded.Signature, LauncherConfig.PublicKeyHex))
+ {
+ Log.Error("manifest signature verification FAILED — refusing to launch");
+ progress.Report(new UpdateProgress { State = LauncherState.SignatureFailed, Percent = 0, Manifest = loaded });
+ throw new ManifestSignatureException(
+ "Ed25519 signature over manifest.json did not verify against the launcher's public key");
+ }
+
+ var manifest = loaded.Manifest;
+ Log.Info($"manifest {manifest.Version} verified, {manifest.Files.Count} files");
+
+ // 3. diff
+ progress.Report(new UpdateProgress { State = LauncherState.Diffing, Manifest = loaded });
+
+ var platform = OperatingSystem.IsWindows() ? "windows" : "linux";
+ var applicable = manifest.Files.Where(f => f.AppliesTo(platform)).ToList();
+
+ var needed = new List<(ManifestFile File, string FinalPath, string StagingPath)>();
+ long totalBytes = 0;
+ foreach (var file in applicable)
+ {
+ ct.ThrowIfCancellationRequested();
+ string finalPath, stagingPath;
+ try
+ {
+ finalPath = PathSafety.ResolveInside(_clientRoot, file.Path);
+ stagingPath = PathSafety.ResolveInside(_stagingDir, file.Path);
+ }
+ catch (UnsafePathException ex)
+ {
+ if (file.IsRequired)
+ {
+ Log.Error($"required file rejected by path safety: {ex.Message}");
+ progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
+ return new Result(false, LauncherState.OfflineFallback, loaded);
+ }
+ Log.Warn($"optional file rejected by path safety: {ex.Message}");
+ continue;
+ }
+
+ var localHash = FileHasher.HashFileOrNull(finalPath);
+ if (localHash == file.Sha256)
+ continue;
+
+ needed.Add((file, finalPath, stagingPath));
+ totalBytes += file.Size;
+ }
+
+ if (needed.Count == 0)
+ {
+ Log.Info("client already up to date");
+ var currentManifestPath = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json");
+ try { File.WriteAllBytes(currentManifestPath, loaded.RawBytes); } catch { }
+ progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
+ return new Result(true, LauncherState.UpToDate, loaded);
+ }
+
+ // 4. download
+ progress.Report(new UpdateProgress { State = LauncherState.Downloading, Percent = 0, Manifest = loaded });
+
+ var downloader = new BlobDownloader(_http, _settings.EffectiveBlobBaseUrl(), LauncherConfig.MaxBlobRetries);
+ var toApply = new List();
+
+ long completedBytes = 0;
+ int idx = 0;
+ foreach (var (file, finalPath, stagingPath) in needed)
+ {
+ ct.ThrowIfCancellationRequested();
+ idx++;
+
+ long lastReported = 0;
+ var perFileProgress = new Progress(b =>
+ {
+ var delta = b - lastReported;
+ lastReported = b;
+ var current = Interlocked.Add(ref completedBytes, delta);
+ var pct = totalBytes > 0 ? (double)current / totalBytes * 100.0 : 0;
+ progress.Report(new UpdateProgress
+ {
+ State = LauncherState.Downloading,
+ Percent = Math.Min(100, pct),
+ Detail = $"{file.Path} ({idx}/{needed.Count})",
+ Manifest = loaded,
+ });
+ });
+
+ try
+ {
+ await downloader.DownloadAsync(file.Sha256, file.Size, stagingPath, ct, perFileProgress).ConfigureAwait(false);
+ toApply.Add(new AtomicApplier.StagedFile(stagingPath, finalPath));
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ if (file.IsRequired)
+ {
+ Log.Error($"required file {file.Path} failed to download", ex);
+ progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
+ return new Result(false, LauncherState.OfflineFallback, loaded);
+ }
+ Log.Warn($"optional file {file.Path} skipped: {ex.Message}");
+ // make sure the bytes from this file don't leave the bar stuck low
+ Interlocked.Add(ref completedBytes, file.Size - lastReported);
+ }
+ }
+
+ // 5. apply
+ progress.Report(new UpdateProgress { State = LauncherState.Applying, Percent = 0, Manifest = loaded });
+ try
+ {
+ int applied = 0;
+ // AtomicApplier doesn't expose progress today; report begin and end.
+ AtomicApplier.Apply(toApply);
+ applied = toApply.Count;
+ progress.Report(new UpdateProgress { State = LauncherState.Applying, Percent = 100, Detail = $"{applied} files", Manifest = loaded });
+ }
+ catch (Exception ex)
+ {
+ Log.Error("apply failed", ex);
+ progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
+ return new Result(false, LauncherState.OfflineFallback, loaded);
+ }
+
+ var current = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json");
+ try { File.WriteAllBytes(current, loaded.RawBytes); } catch { }
+
+ Log.Info($"update complete: {toApply.Count} files applied");
+ progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
+ return new Result(true, LauncherState.UpToDate, loaded);
+ }
+}