From 981d1c99f0a358443ffef0f8559b8c982d0cfe5f Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Thu, 12 Feb 2026 01:52:50 +0400 Subject: [PATCH 1/2] second try to get docker pull progress bar --- .../OpenClawLib/OpenClawLauncher.swift | 144 +++++++++++++++++- .../OpenClawLib/Views/NewLauncherView.swift | 3 +- 2 files changed, 143 insertions(+), 4 deletions(-) diff --git a/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift b/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift index 261ead6..c07ef24 100644 --- a/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift +++ b/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift @@ -842,10 +842,18 @@ public class OpenClawLauncher: ObservableObject { addStep(.running, "Pulling latest image... this may take a moment") pullProgressText = nil - let pull = try await shell("docker", "pull", imageName) + let pullExitCode: Int + if suppressSideEffects { + // In tests, use the mock shell executor + let pull = try await shell("docker", "pull", imageName) + pullExitCode = pull.exitCode + } else { + // In production, stream progress from docker pull + pullExitCode = Int(await pullImageWithProgress(imageName)) + } pullProgressText = nil - if pull.exitCode == 0 { + if pullExitCode == 0 { addStep(.done, "Docker image up to date") return } @@ -857,7 +865,137 @@ public class OpenClawLauncher: ObservableObject { return } - throw LauncherError.pullFailed(pull.stderr.isEmpty ? "Image pull failed" : pull.stderr) + throw LauncherError.pullFailed("Image pull failed (exit code \(pullExitCode))") + } + + /// Streams `docker pull` output and updates `pullProgressText` with download progress. + private func pullImageWithProgress(_ image: String) async -> Int32 { + let process = Process() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["docker", "pull", image] + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + process.environment = DockerPaths.augmentedEnvironment() + + do { + try process.run() + } catch { + logger.error("pullImageWithProgress: failed to start process: \(error.localizedDescription)") + return -1 + } + + // Read stderr incrementally in background and parse progress + let progressTask = Task.detached { [weak self] () -> Void in + let handle = stderrPipe.fileHandleForReading + var buffer = Data() + + while true { + let chunk = handle.availableData + if chunk.isEmpty { break } // EOF + buffer.append(chunk) + + // Parse complete lines from buffer + if let text = String(data: buffer, encoding: .utf8) { + let summary = Self.parsePullProgress(text) + if let summary = summary { + await MainActor.run { [weak self] in + self?.pullProgressText = summary + } + } + } + } + } + + process.waitUntilExit() + progressTask.cancel() + + return process.terminationStatus + } + + /// Parse docker pull stderr output to produce a human-readable progress summary. + /// Docker pull output has lines like: "abc123: Downloading [===> ] 50.12MB/400MB" + /// We aggregate all "Downloading" layers into a total. + nonisolated static func parsePullProgress(_ output: String) -> String? { + let lines = output.split(separator: "\r").last.map { String($0) } ?? output + let allLines = lines.split(separator: "\n") + + var totalDownloaded: Double = 0 + var totalSize: Double = 0 + var downloadingCount = 0 + var doneCount = 0 + var extractingCount = 0 + + for line in allLines { + let s = String(line) + if s.contains("Downloading") { + downloadingCount += 1 + // Parse "50.12MB/400MB" from the line + if let (downloaded, size) = parseSizeFromLine(s) { + totalDownloaded += downloaded + totalSize += size + } + } else if s.contains("Download complete") || s.contains("Already exists") || s.contains("Pull complete") { + doneCount += 1 + } else if s.contains("Extracting") { + extractingCount += 1 + } + } + + if extractingCount > 0 { + return "Extracting layers..." + } + if downloadingCount > 0 && totalSize > 0 { + return "Downloading \(formatBytes(totalDownloaded)) / \(formatBytes(totalSize))" + } + if downloadingCount > 0 { + return "Downloading..." + } + if doneCount > 0 { + return "\(doneCount) layers complete" + } + return nil + } + + /// Parse "50.12MB/400MB" or "50.12kB/400kB" from a docker pull line. + private nonisolated static func parseSizeFromLine(_ line: String) -> (Double, Double)? { + // Match patterns like "123.45MB/678.9MB" or "123.45kB/678.9kB" + let pattern = #"([\d.]+)\s*(kB|MB|GB)/([\d.]+)\s*(kB|MB|GB)"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)), + match.numberOfRanges >= 5 else { return nil } + + func extract(_ idx: Int) -> String? { + guard let range = Range(match.range(at: idx), in: line) else { return nil } + return String(line[range]) + } + + guard let downloadedStr = extract(1), let downloadedUnit = extract(2), + let sizeStr = extract(3), let sizeUnit = extract(4), + let downloaded = Double(downloadedStr), let size = Double(sizeStr) else { return nil } + + return (toBytes(downloaded, unit: downloadedUnit), toBytes(size, unit: sizeUnit)) + } + + private nonisolated static func toBytes(_ value: Double, unit: String) -> Double { + switch unit { + case "kB": return value * 1_000 + case "MB": return value * 1_000_000 + case "GB": return value * 1_000_000_000 + default: return value + } + } + + private nonisolated static func formatBytes(_ bytes: Double) -> String { + if bytes >= 1_000_000_000 { + return String(format: "%.1f GB", bytes / 1_000_000_000) + } else if bytes >= 1_000_000 { + return String(format: "%.0f MB", bytes / 1_000_000) + } else if bytes >= 1_000 { + return String(format: "%.0f kB", bytes / 1_000) + } + return "\(Int(bytes)) B" } private func runContainer() async throws { diff --git a/app/macos/Sources/OpenClawLib/Views/NewLauncherView.swift b/app/macos/Sources/OpenClawLib/Views/NewLauncherView.swift index 042ffb1..cbe5ea2 100644 --- a/app/macos/Sources/OpenClawLib/Views/NewLauncherView.swift +++ b/app/macos/Sources/OpenClawLib/Views/NewLauncherView.swift @@ -233,7 +233,8 @@ public struct NewLauncherView: View { time = nil case .running: status = .active - time = nil + // Show pull progress for the "Container image" step + time = mapping.label == "Container image" ? launcher.pullProgressText : nil case .error: status = .error time = "failed" From e66882c95f1cb15175ff7f29b83b1609743579ad Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Thu, 12 Feb 2026 12:46:50 +0400 Subject: [PATCH 2/2] try to fix launcher docker pull status --- .../OpenClawLib/OpenClawLauncher.swift | 40 +++++++++---------- .../OpenClawLib/Views/NewLauncherView.swift | 8 +++- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift b/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift index c07ef24..053bcd1 100644 --- a/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift +++ b/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift @@ -839,7 +839,7 @@ public class OpenClawLauncher: ObservableObject { } private func ensureImage() async throws { - addStep(.running, "Pulling latest image... this may take a moment") + addStep(.running, "Pulling latest image... first time may take a few minutes") pullProgressText = nil let pullExitCode: Int @@ -869,26 +869,28 @@ public class OpenClawLauncher: ObservableObject { } /// Streams `docker pull` output and updates `pullProgressText` with download progress. + /// Uses `--progress=plain` for predictable newline-delimited output across Docker versions. + /// Progress text is purely cosmetic — exit code alone determines success/failure. private func pullImageWithProgress(_ image: String) async -> Int32 { let process = Process() - let stderrPipe = Pipe() + let outputPipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["docker", "pull", image] - process.standardOutput = FileHandle.nullDevice - process.standardError = stderrPipe + process.arguments = ["docker", "pull", "--progress=plain", image] + process.standardOutput = outputPipe + process.standardError = outputPipe // merge stderr into same pipe process.environment = DockerPaths.augmentedEnvironment() do { try process.run() } catch { - logger.error("pullImageWithProgress: failed to start process: \(error.localizedDescription)") + logger.error("pullImageWithProgress: failed to start: \(error.localizedDescription)") return -1 } - // Read stderr incrementally in background and parse progress + // Read output incrementally — purely cosmetic, failures are silent let progressTask = Task.detached { [weak self] () -> Void in - let handle = stderrPipe.fileHandleForReading + let handle = outputPipe.fileHandleForReading var buffer = Data() while true { @@ -896,7 +898,6 @@ public class OpenClawLauncher: ObservableObject { if chunk.isEmpty { break } // EOF buffer.append(chunk) - // Parse complete lines from buffer if let text = String(data: buffer, encoding: .utf8) { let summary = Self.parsePullProgress(text) if let summary = summary { @@ -914,12 +915,13 @@ public class OpenClawLauncher: ObservableObject { return process.terminationStatus } - /// Parse docker pull stderr output to produce a human-readable progress summary. - /// Docker pull output has lines like: "abc123: Downloading [===> ] 50.12MB/400MB" - /// We aggregate all "Downloading" layers into a total. + /// Parse docker pull output to produce a human-readable progress summary. + /// With `--progress=plain`, output is newline-delimited: + /// "abc123: Downloading 50.12MB / 400MB" + /// "abc123: Download complete" + /// Returns nil if nothing parseable (graceful fallback to static hint). nonisolated static func parsePullProgress(_ output: String) -> String? { - let lines = output.split(separator: "\r").last.map { String($0) } ?? output - let allLines = lines.split(separator: "\n") + let allLines = output.components(separatedBy: .newlines) var totalDownloaded: Double = 0 var totalSize: Double = 0 @@ -928,17 +930,15 @@ public class OpenClawLauncher: ObservableObject { var extractingCount = 0 for line in allLines { - let s = String(line) - if s.contains("Downloading") { + if line.contains("Downloading") { downloadingCount += 1 - // Parse "50.12MB/400MB" from the line - if let (downloaded, size) = parseSizeFromLine(s) { + if let (downloaded, size) = parseSizeFromLine(line) { totalDownloaded += downloaded totalSize += size } - } else if s.contains("Download complete") || s.contains("Already exists") || s.contains("Pull complete") { + } else if line.contains("Download complete") || line.contains("Already exists") || line.contains("Pull complete") { doneCount += 1 - } else if s.contains("Extracting") { + } else if line.contains("Extracting") { extractingCount += 1 } } diff --git a/app/macos/Sources/OpenClawLib/Views/NewLauncherView.swift b/app/macos/Sources/OpenClawLib/Views/NewLauncherView.swift index cbe5ea2..147f6d9 100644 --- a/app/macos/Sources/OpenClawLib/Views/NewLauncherView.swift +++ b/app/macos/Sources/OpenClawLib/Views/NewLauncherView.swift @@ -233,8 +233,12 @@ public struct NewLauncherView: View { time = nil case .running: status = .active - // Show pull progress for the "Container image" step - time = mapping.label == "Container image" ? launcher.pullProgressText : nil + // Show pull progress for the "Container image" step, with static fallback + if mapping.label == "Container image" { + time = launcher.pullProgressText ?? "may take a few minutes" + } else { + time = nil + } case .error: status = .error time = "failed"