diff --git a/privacycommand/Sources/privacycommandCLI/AuditCommand.swift b/privacycommand/Sources/privacycommandCLI/AuditCommand.swift index e21fb29..2042e0f 100644 --- a/privacycommand/Sources/privacycommandCLI/AuditCommand.swift +++ b/privacycommand/Sources/privacycommandCLI/AuditCommand.swift @@ -99,16 +99,20 @@ enum AuditCommand { exit(4) } - // 2. Analyze — with a progress spinner on stderr so a slow app (Chrome - // can take 30s+) doesn't look like a hang. The spinner only draws + // 2. Analyze — with a live status line on stderr so a slow app (Chrome + // can take 30s+) doesn't look like a hang. The board only draws // when stderr is a TTY and never touches stdout, so `--json` and // piped output stay clean. let report: StaticReport do { - let spinner = Spinner(message: "Analyzing \(url.deletingPathExtension().lastPathComponent)…") - spinner.start() - defer { spinner.stop() } - report = try StaticAnalyzer().analyze(bundleAt: url, progress: { spinner.update($0) }) + let name = url.deletingPathExtension().lastPathComponent + let board = StatusBoard() + board.setFooter("Analyzing \(name)") + board.start() + defer { board.stop() } + report = try StaticAnalyzer().analyze(bundleAt: url) { phase in + board.setFooter("Analyzing \(name) — " + StatusBoard.phaseText(phase)) + } } catch { die("failed to analyze \(url.path): \(error.localizedDescription)", code: 1) } diff --git a/privacycommand/Sources/privacycommandCLI/PreviewCommand.swift b/privacycommand/Sources/privacycommandCLI/PreviewCommand.swift index 6986c9a..78c5927 100644 --- a/privacycommand/Sources/privacycommandCLI/PreviewCommand.swift +++ b/privacycommand/Sources/privacycommandCLI/PreviewCommand.swift @@ -1,5 +1,6 @@ import Foundation import privacycommandCore +import privacycommandCLIKit /// `privacycommand preview` — analyze the apps you're about to update and surface /// anything noteworthy, *before* you run `brew upgrade`. @@ -38,6 +39,7 @@ enum PreviewCommand { --fetch, else on the installed one. Never runs brew. --min-tier only show apps at risk tier >= low|medium|high|critical --only-noteworthy hide apps with nothing noteworthy + --no-color disable coloured output (the NO_COLOR env var also works) --json emit machine-readable JSON -h, --help show this help @@ -82,6 +84,7 @@ enum PreviewCommand { --min-tier only show apps at risk tier >= low|medium|high|critical (not with --max-risk, which judges every cask) --only-noteworthy hide apps with nothing noteworthy (not with --max-risk) + --no-color disable coloured output (the NO_COLOR env var also works) --json emit machine-readable JSON (brew's output goes to stderr) -h, --help show this help @@ -118,6 +121,7 @@ enum PreviewCommand { var threshold: UpgradeGate.Threshold? var dryRun = false var noInput = false + var noColor = false var caskFilter: [String] = [] // positional cask tokens to restrict to var i = 0 @@ -137,6 +141,8 @@ enum PreviewCommand { dryRun = true case "--no-input": noInput = true + case "--no-color": + noColor = true case "--apps-dir": i += 1 guard i < argv.count else { die("--apps-dir needs a directory") } @@ -191,6 +197,15 @@ enum PreviewCommand { die("--dry-run/--no-input need --max-risk (without it, `upgrade` never runs brew).") } + let ansi = Ansi(noColor: noColor) + + // Live progress on stderr (a terminal only) from here until the + // report: one row per app once they're known, so the wait for brew, + // the analysis and any downloads isn't silent. + let board = StatusBoard() + board.setFooter("Starting") + board.start() + // 1. Discover the apps to preview. let targets: [HomebrewCaskInventory.PreviewTarget] var scan: HomebrewCaskInventory.OutdatedScan? @@ -199,11 +214,16 @@ enum PreviewCommand { if allApps { let dirs = appsDir.map { [URL(fileURLWithPath: ($0 as NSString).expandingTildeInPath)] } ?? HomebrewCaskInventory.defaultAppDirectories() + board.setFooter("Listing installed apps") targets = HomebrewCaskInventory.installedApps(in: dirs) header = "Scanning \(targets.count) installed app\(plural(targets.count))…" } else { - var found = try HomebrewCaskInventory().outdatedScan(greedy: greedy) - if !caskFilter.isEmpty { found = restrict(found, to: caskFilter) } + var found = try HomebrewCaskInventory().outdatedScan(greedy: greedy) { board.setFooter($0) } + if !caskFilter.isEmpty { + let (restricted, notes) = restrict(found, to: caskFilter) + found = restricted + notes.forEach { board.log($0) } + } scan = found targets = found.targets let total = targets.count + found.skipped.count @@ -213,10 +233,12 @@ enum PreviewCommand { "(\(found.skipped.count) can't be previewed — listed at the end)…" } } catch { + board.stop() die((error as? LocalizedError)?.errorDescription ?? error.localizedDescription) } if targets.isEmpty && (scan?.skipped.isEmpty ?? true) { + board.stop() if json { print("[]") } else if allApps { @@ -230,8 +252,16 @@ enum PreviewCommand { exit(0) } + // The header goes out now rather than with the report, so the run says + // what it's checking straight away. + if !json { board.log(header, toStdout: true) } + board.setRows(targets.map { t in + ProgressBoard.Row(id: rowID(t), state: .queued, name: t.displayName, + version: versionColumn(t), status: "queued") + }) + // 2. Analyze the installed builds (parallel — I/O-bound on codesign/spctl). - var results = analyze(targets) + var results = analyze(targets, board: board) // Display filter, reused for both the fetch set and rendering. let shouldShow: (Result) -> Bool = { r in @@ -246,8 +276,9 @@ enum PreviewCommand { // --min-tier bound how much gets downloaded. (With --max-risk those // filters are rejected above, so every cask is fetched and judged.) if fetch { - runFetchPass(&results, shouldShow: shouldShow) + runFetchPass(&results, shouldShow: shouldShow, board: board) } + board.stop() // 4. Gate: decide cleared / held per cask. if let threshold { @@ -271,12 +302,12 @@ enum PreviewCommand { emitJSON(results.filter(shouldShow), skipped: scan?.skipped ?? [], fetched: fetch, threshold: threshold) } else { - render(header: header, all: results, shown: results.filter(shouldShow), scan: scan, - fetched: fetch, threshold: threshold, willUpgrade: upgrade, greedy: greedy) + render(all: results, shown: results.filter(shouldShow), scan: scan, + fetched: fetch, threshold: threshold, willUpgrade: upgrade, greedy: greedy, ansi: ansi) if upgrade, threshold != nil { let interactive = !noInput && isatty(STDIN_FILENO) != 0 && isatty(STDOUT_FILENO) != 0 brewFailed = applyUpgrades(&results, greedy: greedy, dryRun: dryRun, - askAboutHeld: interactive, json: false) + askAboutHeld: interactive, json: false, ansi: ansi) renderUpgradeOutcome(results, greedy: greedy, dryRun: dryRun) } } @@ -297,22 +328,21 @@ enum PreviewCommand { return stillHeld ? 3 : 0 } - /// Restrict a scan to the named casks, noting any that aren't outdated. - /// Formula / greedy notes are dropped — they're about the whole upgrade. + /// Restrict a scan to the named casks, with a note for each that isn't + /// outdated. Formula / greedy notes are dropped — they're about the whole upgrade. static func restrict(_ scan: HomebrewCaskInventory.OutdatedScan, - to tokens: [String]) -> HomebrewCaskInventory.OutdatedScan { + to tokens: [String]) -> (HomebrewCaskInventory.OutdatedScan, notes: [String]) { let wanted = Set(tokens.map { $0.lowercased() }) let targets = scan.targets.filter { token(of: $0).map { wanted.contains($0.lowercased()) } ?? false } let skipped = scan.skipped.filter { wanted.contains($0.token.lowercased()) } let found = Set(targets.compactMap { token(of: $0)?.lowercased() } + skipped.map { $0.token.lowercased() }) - for missing in wanted.subtracting(found).sorted() { + let notes = wanted.subtracting(found).sorted().map { missing in let greedyOnly = scan.greedyOnlyCasks.contains { $0.lowercased() == missing } let hint = greedyOnly ? " (it only updates with --greedy)" : "" - FileHandle.standardError.write(Data( - "note: ‘\(missing)’ is not an outdated cask\(hint) — skipping.\n".utf8)) + return "note: ‘\(missing)’ is not an outdated cask\(hint) — skipping." } - return HomebrewCaskInventory.OutdatedScan(targets: targets, skipped: skipped, - outdatedFormulae: [], greedyOnlyCasks: []) + return (HomebrewCaskInventory.OutdatedScan(targets: targets, skipped: skipped, + outdatedFormulae: [], greedyOnlyCasks: []), notes) } private static func token(of target: HomebrewCaskInventory.PreviewTarget) -> String? { @@ -350,38 +380,73 @@ enum PreviewCommand { var upgradeStatus: UpgradeStatus? } - static func analyze(_ targets: [HomebrewCaskInventory.PreviewTarget]) -> [Result] { - var slots = [Result?](repeating: nil, count: targets.count) + static func analyze(_ targets: [HomebrewCaskInventory.PreviewTarget], board: StatusBoard) -> [Result] { + let total = targets.count + board.setFooter("Analyzing installed apps — 0 of \(total) done") + var slots = [Result?](repeating: nil, count: total) + var done = 0 let lock = NSLock() - DispatchQueue.concurrentPerform(iterations: targets.count) { idx in + DispatchQueue.concurrentPerform(iterations: total) { idx in let target = targets[idx] + let id = rowID(target) + board.update(id) { $0.state = .working; $0.status = "reading the app" } let result: Result do { - let report = try StaticAnalyzer().analyze(bundleAt: target.bundleURL) + let report = try StaticAnalyzer().analyze(bundleAt: target.bundleURL) { phase in + board.update(id) { $0.status = StatusBoard.phaseText(phase) } + } + let summary = NoteworthySummary.summarize(report) result = Result(target: target, installedVersion: report.bundle.bundleVersion, - summary: NoteworthySummary.summarize(report), + summary: summary, installedReport: report, error: nil) + board.update(id) { + $0.state = summary.isNoteworthy ? .noteworthy : .clean + $0.status = boardStatus(summary) + } } catch { result = Result(target: target, installedVersion: nil, summary: nil, installedReport: nil, error: error.localizedDescription) + board.update(id) { $0.state = .failed; $0.status = "couldn't analyze — " + error.localizedDescription } } - lock.lock(); slots[idx] = result; lock.unlock() + lock.lock(); slots[idx] = result; done += 1; let n = done; lock.unlock() + board.setFooter("Analyzing installed apps — \(n) of \(total) done") } return slots.compactMap { $0 } } + /// The board row's key: the cask token, or the bundle path for --all-apps. + static func rowID(_ target: HomebrewCaskInventory.PreviewTarget) -> String { + token(of: target) ?? target.bundleURL.path + } + + /// `3.7.0 → 3.7.4` for a cask row; nothing for a plain installed app. + static func versionColumn(_ target: HomebrewCaskInventory.PreviewTarget) -> String? { + guard case let .brewCask(_, installed, available) = target.source, + let installed, let available else { return nil } + return "\(displayVersion(installed)) → \(displayVersion(available))" + } + + /// A row's one-line outcome: `low risk 14 · 4 findings · 2 signals`. + static func boardStatus(_ s: NoteworthySummary) -> String { + var bits = ["\(s.tier.label.lowercased()) risk \(s.riskScore)"] + if !s.findings.isEmpty { bits.append("\(s.findings.count) finding\(plural(s.findings.count))") } + if !s.signals.isEmpty { bits.append("\(s.signals.count) signal\(plural(s.signals.count))") } + return s.isNoteworthy ? bits.joined(separator: " · ") : "nothing noteworthy · " + bits[0] + } + // MARK: - Fetch pass (download incoming cask → analyze → diff) /// For each shown brew-cask target, download the incoming artifact, analyze /// it, and diff against the installed build. Sequential (downloads are large /// and network-bound). Bridges the synchronous CLI to the async fetch API /// with a Task + semaphore so the process never exits before teardown. - static func runFetchPass(_ results: inout [Result], shouldShow: (Result) -> Bool) { + static func runFetchPass(_ results: inout [Result], shouldShow: (Result) -> Bool, board: StatusBoard) { struct Job: Sendable { let index: Int + let id: String let token: String let installed: StaticReport? let appName: String @@ -392,23 +457,47 @@ enum PreviewCommand { for i in results.indices where shouldShow(results[i]) { let target = results[i].target guard let token = token(of: target) else { continue } - jobs.append(Job(index: i, token: token, installed: results[i].installedReport, + jobs.append(Job(index: i, id: rowID(target), token: token, installed: results[i].installedReport, appName: target.bundleURL.lastPathComponent, pkgPath: target.incomingPkgPath)) } guard !jobs.isEmpty else { return } - FileHandle.standardError.write(Data( - "Fetching \(jobs.count) incoming build\(plural(jobs.count))… (downloads may be large)\n".utf8)) + // On a terminal the board shows each step; elsewhere (logs, CI) one + // plain line per cask is enough. + if !board.enabled { + board.log("Fetching \(jobs.count) incoming build\(plural(jobs.count))… (downloads may be large)") + } + for job in jobs { board.update(job.id) { $0.state = .queued; $0.status = "waiting to download" } } final class Box: @unchecked Sendable { var map: [Int: FetchOutcome] = [:] } let box = Box() let semaphore = DispatchSemaphore(value: 0) let jobsCopy = jobs Task { - for job in jobsCopy { - FileHandle.standardError.write(Data(" fetching \(job.token)…\n".utf8)) - box.map[job.index] = await fetchOne(token: job.token, installed: job.installed, - appName: job.appName, pkgPath: job.pkgPath) + for (n, job) in jobsCopy.enumerated() { + board.setFooter("Fetching incoming builds — \(n + 1) of \(jobsCopy.count)") + if !board.enabled { board.log(" fetching \(job.token)…") } + board.update(job.id) { $0.state = .working; $0.status = "looking up the download" } + let outcome = await fetchOne( + token: job.token, installed: job.installed, appName: job.appName, pkgPath: job.pkgPath, + progress: { phase in + switch phase { + case .downloading(let cacheFile): + board.update(job.id, live: { downloadStatus(cacheFile) }) { + $0.state = .downloading; $0.status = "downloading" + } + case .unpacking: + board.update(job.id) { $0.state = .working; $0.status = "opening the download" } + } + }, + analyzing: { step in + board.update(job.id) { + $0.state = .working; $0.status = "analyzing the new build — " + StatusBoard.phaseText(step) + } + }) + box.map[job.index] = outcome + let (state, status) = boardOutcome(outcome) + board.update(job.id) { $0.state = state; $0.status = status } } semaphore.signal() } @@ -417,14 +506,29 @@ enum PreviewCommand { for (i, outcome) in box.map { results[i].fetched = outcome } } + /// How a cask's row ends after the fetch pass. + static func boardOutcome(_ outcome: FetchOutcome) -> (ProgressBoard.Row.State, String) { + switch outcome { + case .analyzed(let d): + let n = d.diff.changedSections.reduce(0) { $0 + $1.added.count + $1.removed.count + $1.modified.count } + guard n > 0 else { return (.clean, "no privacy-relevant changes") } + return (.noteworthy, "\(n) change\(plural(n)) · risk \(d.installedRiskScore) → \(d.incomingRiskScore)") + case .skipped(let why): return (.skipped, "skipped — " + why) + case .failed(let why): return (.failed, "couldn't fetch — " + why) + } + } + static func fetchOne(token: String, installed: StaticReport?, - appName: String, pkgPath: String?) async -> FetchOutcome { + appName: String, pkgPath: String?, + progress: ((CaskArtifactFetcher.Phase) -> Void)? = nil, + analyzing: ((String) -> Void)? = nil) async -> FetchOutcome { guard let installed else { return .skipped("installed build couldn't be analyzed") } do { let incoming = try await CaskArtifactFetcher.withDownloadedApp( - token: token, preferredAppName: appName, pkgPath: pkgPath + token: token, preferredAppName: appName, pkgPath: pkgPath, progress: progress ) { app in - try StaticAnalyzer().analyze(bundleAt: app) + analyzing?("Reading the app") + return try StaticAnalyzer().analyze(bundleAt: app) { analyzing?($0) } } return .analyzed(IncomingCaskComparison.compare(installed: installed, incoming: incoming)) } catch let e as CaskArtifactFetcher.FetchError { @@ -435,6 +539,25 @@ enum PreviewCommand { } } + /// A downloading row's live status: how much `brew fetch` has written so + /// far (brew keeps it in `.incomplete`), or that the download + /// is complete and brew is verifying it. + static func downloadStatus(_ cacheFile: URL) -> String { + let fm = FileManager.default + let partial = CaskArtifactFetcher.partialFile(for: cacheFile) + if let size = (try? fm.attributesOfItem(atPath: partial.path))?[.size] as? Int64 { + return "downloading — " + ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + " so far" + } + // Finished (or already cached from an earlier run): brew checks it now. + if fm.fileExists(atPath: cacheFile.path) { return "downloaded — checking its checksum" } + return "starting the download" + } + + /// A cask version without brew's build suffix: `3.23.23,2dac24…` → `3.23.23`. + static func displayVersion(_ version: String) -> String { + String(version.split(separator: ",", maxSplits: 1).first ?? Substring(version)) + } + // MARK: - Gate (--max-risk) /// What the gate gets to judge for one cask. With --fetch that's the @@ -459,7 +582,7 @@ enum PreviewCommand { /// a time so each cask gets an honest status. Records the status on each /// result; returns true if any brew run failed. static func applyUpgrades(_ results: inout [Result], greedy: Bool, dryRun: Bool, - askAboutHeld: Bool, json: Bool) -> Bool { + askAboutHeld: Bool, json: Bool, ansi: Ansi = Ansi(enabled: false)) -> Bool { var toUpgrade: [Int] = [] for i in results.indices { guard let d = results[i].decision, token(of: results[i].target) != nil else { continue } @@ -476,8 +599,8 @@ enum PreviewCommand { for i in held { guard let token = token(of: results[i].target) else { continue } print("") - print(line(for: results[i])) - if let reason = results[i].decision?.reason { print(" held: \(reason)") } + print(line(for: results[i], ansi: ansi)) + if let reason = results[i].decision?.reason { print(" " + ansi.paint("held: \(reason)", .red)) } print("Upgrade \(token) anyway? [y/N] ", terminator: "") fflush(stdout) guard let answer = readLine(strippingNewline: true) else { print(""); break } // EOF: stop asking @@ -522,11 +645,11 @@ enum PreviewCommand { // MARK: - Human rendering - static func render(header: String, all: [Result], shown: [Result], + /// The report under the header, which `run` prints before the analysis. + static func render(all: [Result], shown: [Result], scan: HomebrewCaskInventory.OutdatedScan?, fetched: Bool, threshold: UpgradeGate.Threshold? = nil, willUpgrade: Bool = false, - greedy: Bool = false) { - print(header) + greedy: Bool = false, ansi: Ansi = Ansi(enabled: false)) { print("") // With a gate: held first. Then noteworthy first, then by risk tier @@ -541,23 +664,25 @@ enum PreviewCommand { } for r in ordered { - print(line(for: r)) + print(line(for: r, ansi: ansi)) if let d = r.decision, !d.cleared, let reason = d.reason { - print(" held: \(reason)") + print(" " + ansi.paint("held: \(reason)", .red)) } if let summary = r.summary { if !summary.findings.isEmpty { print(" findings:") - for f in summary.findings { print(" [\(f.severity.rawValue)] \(f.message)") } + for f in summary.findings { + print(" \(AuditCommand.severityTag(f.severity, ansi: ansi)) \(f.message)") + } } if !summary.signals.isEmpty { print(" signals:") for s in summary.signals { print(" • \(s)") } } } else if let err = r.error { - print(" couldn't analyze: \(err)") + print(" " + ansi.paint("couldn't analyze: \(err)", .red)) } - if let outcome = r.fetched { renderIncoming(outcome) } + if let outcome = r.fetched { renderIncoming(outcome, ansi: ansi) } print("") } @@ -568,8 +693,10 @@ enum PreviewCommand { : "Not analyzed, so not upgraded here — update \(these) yourself:") for s in skipped { var head = " – \(s.token)" - if let i = s.installedVersion, let a = s.availableVersion { head += " \(i) → \(a)" } - print(head + " — " + s.reason) + if let i = s.installedVersion, let a = s.availableVersion { + head += " \(displayVersion(i)) → \(displayVersion(a))" + } + print(ansi.paint(head + " — " + s.reason, .dim)) } if threshold != nil { print(" " + HomebrewUpgrader.commandLine(casks: skipped.map(\.token), greedy: greedy)) @@ -592,11 +719,11 @@ enum PreviewCommand { }.count print("\(changed) of \(analyzedIncoming.count) fetched incoming build\(plural(analyzedIncoming.count)) " + "\(analyzedIncoming.count == 1 ? "has" : "have") privacy-relevant changes.") - print("Incoming builds are analyzed before Gatekeeper clearance, so a 'notarization' " + - "difference can be an artifact of the fresh download rather than a real change.") + print(ansi.paint("Incoming builds are analyzed before Gatekeeper clearance, so a 'notarization' " + + "difference can be an artifact of the fresh download rather than a real change.", .dim)) } else if scan != nil { - print("Analysis is of the installed version — it previews what each app already " + - "does, not the incoming build. Re-run with --fetch to analyze the incoming build.") + print(ansi.paint("Analysis is of the installed version — it previews what each app already " + + "does, not the incoming build. Re-run with --fetch to analyze the incoming build.", .dim)) } if let scan { printBrewNotes(scan) } @@ -669,25 +796,27 @@ enum PreviewCommand { /// The per-cask "incoming build" block shown under each result when --fetch /// is on. - private static func renderIncoming(_ outcome: FetchOutcome) { + private static func renderIncoming(_ outcome: FetchOutcome, ansi: Ansi) { switch outcome { case .analyzed(let d): let version = d.incomingVersion.map { " \($0)" } ?? "" - print(" incoming\(version): risk \(d.installedRiskScore) → \(d.incomingRiskScore) (\(deltaLabel(d.riskScoreDelta)))") + let delta = d.riskScoreDelta + let deltaText = ansi.paint("(\(deltaLabel(delta)))", delta > 0 ? .yellow : delta < 0 ? .green : .dim) + print(" incoming\(version): risk \(d.installedRiskScore) → \(d.incomingRiskScore) \(deltaText)") let changed = d.diff.changedSections if changed.isEmpty { - print(" no privacy-relevant changes") + print(" " + ansi.paint("no privacy-relevant changes", .green)) } else { for sec in changed { - for a in sec.added { print(" + \(sec.title): \(a)") } - for rm in sec.removed { print(" − \(sec.title): \(rm)") } + for a in sec.added { print(" " + ansi.paint("+ \(sec.title): \(a)", .yellow)) } + for rm in sec.removed { print(" " + ansi.paint("− \(sec.title): \(rm)", .dim)) } for m in sec.modified { print(" ~ \(sec.title): \(m.before) → \(m.after)") } } } case .skipped(let why): - print(" incoming: skipped — \(why)") + print(" " + ansi.paint("incoming: skipped — \(why)", .dim)) case .failed(let why): - print(" incoming: could not fetch — \(why)") + print(" " + ansi.paint("incoming: could not fetch — \(why)", .red)) } } @@ -695,25 +824,27 @@ enum PreviewCommand { delta > 0 ? "Δ+\(delta)" : "Δ\(delta)" // negative already carries its sign } - private static func line(for r: Result) -> String { + private static func line(for r: Result, ansi: Ansi = Ansi(enabled: false)) -> String { // With a gate the marker is the verdict; otherwise it's "noteworthy?". let marker: String if let d = r.decision { - marker = d.cleared ? "✓ cleared " : "⚠ review " + marker = d.cleared ? ansi.paint("✓ cleared", .green) + " " : ansi.paint("⚠ review", .yellow) + " " } else { - marker = (r.summary?.isNoteworthy ?? true) ? "⚠ " : "✓ " + marker = (r.summary?.isNoteworthy ?? true) ? ansi.paint("⚠", .yellow) + " " : ansi.paint("✓", .green) + " " } - var head = marker + r.target.displayName + var head = marker + ansi.paint(r.target.displayName, .bold) if case let .brewCask(token, installed, available) = r.target.source { - head += " (\(token))" - if let installed, let available { head += " \(installed) → \(available)" } + head += " " + ansi.paint("(\(token))", .dim) + if let installed, let available { + head += " \(displayVersion(installed)) → \(displayVersion(available))" + } } else if let v = r.installedVersion { head += " v\(v)" } if let summary = r.summary { - head += " — " + summary.headline + head += " — " + ansi.paint(summary.headline, AuditCommand.tierCode(summary.tier)) } else { - head += " — analysis failed" + head += " — " + ansi.paint("analysis failed", .red) } return head } diff --git a/privacycommand/Sources/privacycommandCLI/Spinner.swift b/privacycommand/Sources/privacycommandCLI/Spinner.swift deleted file mode 100644 index 85a6638..0000000 --- a/privacycommand/Sources/privacycommandCLI/Spinner.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation - -/// A progress spinner for the blocking one-shot audit. The analyzer runs -/// synchronously on the main thread and feeds phase text in via `update`; this -/// animates a braille frame + elapsed seconds on a background queue so a slow -/// app (Chrome can take 30s+) shows steady progress instead of looking hung. -/// -/// It writes **only to stderr** and **only when stderr is a TTY**, so -/// `--json` / redirected stdout stays clean and piped/CI runs print nothing. -final class Spinner { - - private static let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - - private let enabled: Bool - private let queue = DispatchQueue(label: "com.privacykey.privacycommand.spinner") - private let done = DispatchSemaphore(value: 0) - private let lock = NSLock() - private var message: String - private var running = false - private var startTime = Date() - - init(message: String, forceEnabled: Bool = false) { - self.message = message - self.enabled = forceEnabled || isatty(FileHandle.standardError.fileDescriptor) != 0 - } - - func start() { - guard enabled else { return } - lock.lock(); running = true; startTime = Date(); lock.unlock() - queue.async { [weak self] in - guard let self else { return } - var i = 0 - while true { - self.lock.lock() - let go = self.running - let msg = self.message - let start = self.startTime - self.lock.unlock() - if !go { break } - let secs = Int(Date().timeIntervalSince(start)) - let frame = Self.frames[i % Self.frames.count] - // \r to column 0, \e[K clears to EOL, then repaint. - Self.writeStderr("\r\u{1B}[K\(frame) \(msg) (\(secs)s)") - i += 1 - Thread.sleep(forTimeInterval: 0.1) - } - self.done.signal() - } - } - - /// Swap the phase label (called from the analyzer's progress callback). - func update(_ msg: String) { - guard enabled else { return } - lock.lock(); message = msg; lock.unlock() - } - - /// Stop the animation and clear the line so the report starts on a clean row. - func stop() { - guard enabled else { return } - lock.lock(); let wasRunning = running; running = false; lock.unlock() - if wasRunning { done.wait() } // let the loop finish its current frame - Self.writeStderr("\r\u{1B}[K") - } - - private static func writeStderr(_ s: String) { - FileHandle.standardError.write(Data(s.utf8)) - } -} diff --git a/privacycommand/Sources/privacycommandCLI/StatusBoard.swift b/privacycommand/Sources/privacycommandCLI/StatusBoard.swift new file mode 100644 index 0000000..a18ae0d --- /dev/null +++ b/privacycommand/Sources/privacycommandCLI/StatusBoard.swift @@ -0,0 +1,160 @@ +import Foundation +import privacycommandCLIKit + +/// Live progress for blocking CLI work, drawn in place on stderr: the app +/// rows of a `ProgressBoard` (none for a single job, such as the one-shot +/// audit) and a footer with a braille spinner and the elapsed time. A +/// background queue repaints it ten times a second; callers update rows and +/// the footer from any thread. +/// +/// It draws **only when stderr is a TTY**, so `--json` / redirected stdout +/// stays clean and piped or CI runs print nothing; `enabled` tells callers +/// when to fall back to plain progress lines. `log` prints a whole line above +/// the board either way, so a note never lands in the middle of a frame. +final class StatusBoard: @unchecked Sendable { // every field is guarded by `lock` + + private static let frames: [Character] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + + /// True when the board animates (stderr is a terminal). + let enabled: Bool + private let ansi: Ansi + private let queue = DispatchQueue(label: "com.privacykey.privacycommand.statusboard") + private let done = DispatchSemaphore(value: 0) + private let lock = NSLock() + + private var rows: [ProgressBoard.Row] = [] + /// Per-row text re-read on every frame — e.g. the bytes downloaded so far. + private var liveStatus: [String: () -> String?] = [:] + private var footer = "" + private var liveFooter: (() -> String?)? + private var running = false + private var painted = 0 // lines currently on screen, for the redraw + private var startTime = Date() + + init(forceEnabled: Bool = false) { + enabled = forceEnabled || isatty(STDERR_FILENO) != 0 + // Colour follows the NO_COLOR convention; --no-color governs the + // report on stdout and is applied by the caller through `log`. + let noColor = ProcessInfo.processInfo.environment["NO_COLOR"]?.isEmpty == false + ansi = Ansi(enabled: enabled && !noColor) + } + + // MARK: - Lifecycle + + func start() { + guard enabled else { return } + lock.lock(); running = true; startTime = Date(); lock.unlock() + queue.async { [weak self] in + guard let self else { return } + var i = 0 + while true { + self.lock.lock() + guard self.running else { self.lock.unlock(); break } + self.paint(frame: Self.frames[i % Self.frames.count]) + self.lock.unlock() + i += 1 + Thread.sleep(forTimeInterval: 0.1) + } + self.done.signal() + } + } + + /// Stop the animation and erase the board so the report starts on a clean row. + func stop() { + guard enabled else { return } + lock.lock(); let wasRunning = running; running = false; lock.unlock() + guard wasRunning else { return } + done.wait() // let the loop finish its current frame + lock.lock(); clear(); lock.unlock() + } + + // MARK: - Content + + /// The line under the rows (or the only line): what's happening overall. + /// `live`, when given, is re-read on every frame and appended after a dash. + func setFooter(_ text: String, live: (() -> String?)? = nil) { + lock.lock(); footer = text; liveFooter = live; lock.unlock() + } + + func setRows(_ rows: [ProgressBoard.Row]) { + lock.lock(); self.rows = rows; liveStatus = [:]; lock.unlock() + } + + /// Change one row. `live`, when given, replaces its status text on every + /// frame until the next update. + func update(_ id: String, live: (() -> String?)? = nil, + _ change: (inout ProgressBoard.Row) -> Void) { + lock.lock(); defer { lock.unlock() } + guard let i = rows.firstIndex(where: { $0.id == id }) else { return } + change(&rows[i]) + liveStatus[id] = live + } + + /// Print a whole line above the board (or plainly when it isn't + /// animating) — to stderr for a note, or to stdout for the report's own + /// first lines. + func log(_ line: String, toStdout: Bool = false) { + lock.lock(); defer { lock.unlock() } + clear() + if toStdout { + print(line) + fflush(stdout) + } else { + Self.write(line + "\n") + } + } + + // MARK: - Painting (lock held) + + private func paint(frame: Character) { + let (columns, lines) = Self.terminalSize() + let composed = rows.map { row -> ProgressBoard.Row in + guard let live = liveStatus[row.id], let text = live() else { return row } + var r = row; r.status = text; return r + } + var footerText = footer + if let extra = liveFooter?(), !extra.isEmpty { footerText += " — " + extra } + let secs = Int(Date().timeIntervalSince(startTime)) + footerText += " · \(secs)s" + + // Leave room for the header above, the prompt below, and a margin. + let board = ProgressBoard.render(rows: composed, footer: footerText, frame: frame, + width: columns, maxRows: max(0, lines - 4), ansi: ansi) + // \r to column 0, up to the board's first line, repaint each line + // cleared to EOL, then clear whatever an earlier, taller frame left. + var out = "\r" + if painted > 1 { out += "\u{1B}[\(painted - 1)A" } + out += board.map { $0 + "\u{1B}[K" }.joined(separator: "\n") + out += "\u{1B}[J" + Self.write(out) + painted = board.count + } + + private func clear() { + guard painted > 0 else { return } + var out = "\r" + if painted > 1 { out += "\u{1B}[\(painted - 1)A" } + out += "\u{1B}[J" + Self.write(out) + painted = 0 + } + + /// An analyzer step as a row or footer reads: `Checking code signature` + /// → `checking code signature`. + static func phaseText(_ phase: String) -> String { + guard let first = phase.first else { return phase } + return first.lowercased() + phase.dropFirst() + } + + private static func terminalSize() -> (columns: Int, lines: Int) { + var size = winsize() + guard ioctl(STDERR_FILENO, TIOCGWINSZ, &size) == 0, size.ws_col > 0, size.ws_row > 0 else { + return (80, 24) + } + return (Int(size.ws_col), Int(size.ws_row)) + } + + private static func write(_ s: String) { + FileHandle.standardError.write(Data(s.utf8)) + } +} diff --git a/privacycommand/Sources/privacycommandCLI/TerminalDriver.swift b/privacycommand/Sources/privacycommandCLI/TerminalDriver.swift index feb540f..18edee1 100644 --- a/privacycommand/Sources/privacycommandCLI/TerminalDriver.swift +++ b/privacycommand/Sources/privacycommandCLI/TerminalDriver.swift @@ -35,19 +35,21 @@ final class TerminalDriver { private var running = true private var lastBodyHeight = 10 - /// Thread-safe hand-off from the analysis worker back to the main loop. - private final class Inbox: @unchecked Sendable { + /// Thread-safe hand-off from the analysis worker back to the main loop: + /// finished states, and the analyzer's steps while it works. + private final class Inbox: @unchecked Sendable { private let lock = NSLock() - private var items: [(String, AuditState)] = [] - func push(_ path: String, _ state: AuditState) { - lock.lock(); items.append((path, state)); lock.unlock() + private var items: [(String, Item)] = [] + func push(_ path: String, _ item: Item) { + lock.lock(); items.append((path, item)); lock.unlock() } - func drain() -> [(String, AuditState)] { + func drain() -> [(String, Item)] { lock.lock(); defer { items.removeAll(); lock.unlock() } return items } } - private let inbox = Inbox() + private let inbox = Inbox() + private let phases = Inbox() private let analysisQueue = DispatchQueue(label: "com.privacykey.privacycommand.tui.analysis") init(model: AppBrowserModel) { self.model = model } @@ -62,11 +64,11 @@ final class TerminalDriver { var dirty = true while running { + let steps = phases.drain() + for (path, phase) in steps { model.setPhase(phase, forPath: path) } let completed = inbox.drain() - if !completed.isEmpty { - for (path, state) in completed { model.setState(state, forPath: path) } - dirty = true - } + for (path, state) in completed { model.setState(state, forPath: path) } + if !steps.isEmpty || !completed.isEmpty { dirty = true } if gResized != 0 { gResized = 0; dirty = true } let (w, h) = terminalSize() @@ -100,10 +102,11 @@ final class TerminalDriver { model.markAnalyzing(path: app.path) let (path, url, name) = (app.path, app.url, app.name) let inbox = self.inbox + let phases = self.phases analysisQueue.async { let state: AuditState do { - let report = try StaticAnalyzer().analyze(bundleAt: url) + let report = try StaticAnalyzer().analyze(bundleAt: url) { phases.push(path, $0) } let summary = NoteworthySummary.summarize(report) state = .done(AuditSnapshot(report: report, summary: summary, fallbackName: name)) } catch { diff --git a/privacycommand/Sources/privacycommandCLIKit/AppBrowserModel.swift b/privacycommand/Sources/privacycommandCLIKit/AppBrowserModel.swift index 65b90dd..ac58fbd 100644 --- a/privacycommand/Sources/privacycommandCLIKit/AppBrowserModel.swift +++ b/privacycommand/Sources/privacycommandCLIKit/AppBrowserModel.swift @@ -146,10 +146,20 @@ public struct AppBrowserModel: Sendable { public mutating func setState(_ s: AuditState, forPath path: String) { states[path] = s + phases[path] = nil } public mutating func markAnalyzing(path: String) { states[path] = .analyzing + phases[path] = nil + } + + /// The analyzer's current step for an app being analyzed, shown under + /// "Analyzing …" so a slow app doesn't look stuck. Cleared with the state. + public private(set) var phases: [String: String] = [:] + + public mutating func setPhase(_ phase: String, forPath path: String) { + phases[path] = phase } /// Drop the selected app's cached result so the driver re-runs analysis. diff --git a/privacycommand/Sources/privacycommandCLIKit/ProgressBoard.swift b/privacycommand/Sources/privacycommandCLIKit/ProgressBoard.swift new file mode 100644 index 0000000..3a25a19 --- /dev/null +++ b/privacycommand/Sources/privacycommandCLIKit/ProgressBoard.swift @@ -0,0 +1,173 @@ +import Foundation + +/// Lays out the live progress board `preview` / `upgrade` draw while they +/// work: one row per app plus a footer carrying the spinner, every line +/// clipped to the terminal so nothing wraps (a wrapped line would break the +/// redraw-in-place cursor maths). Pure — rows, a width and a row budget in, +/// lines out — so the layout is unit-testable. Markers and colours match the +/// interactive browser's list: `·` queued, `~` working, `↓` downloading, +/// `✓` clean, `⚠` noteworthy, `✗` failed, `–` skipped. +public enum ProgressBoard { + + public struct Row: Sendable, Equatable { + public enum State: Sendable, Equatable { + case queued + case working + case downloading + case clean + case noteworthy + case failed + case skipped + + /// Finished one way or another — the first rows hidden when the + /// board is taller than the terminal. + public var isDone: Bool { + switch self { + case .clean, .noteworthy, .failed, .skipped: return true + case .queued, .working, .downloading: return false + } + } + } + + public var id: String + public var state: State + public var name: String + /// e.g. `3.7.0 → 3.7.4`. The first column dropped on a narrow terminal. + public var version: String? + /// What's happening to it right now, or how it ended. + public var status: String + + public init(id: String, state: State, name: String, version: String? = nil, status: String) { + self.id = id + self.state = state + self.name = name + self.version = version + self.status = status + } + } + + public static func marker(for state: Row.State) -> (Character, Ansi.Code) { + switch state { + case .queued: return ("·", .dim) + case .working: return ("~", .yellow) + case .downloading: return ("↓", .cyan) + case .clean: return ("✓", .green) + case .noteworthy: return ("⚠", .yellow) + case .failed: return ("✗", .red) + case .skipped: return ("–", .dim) + } + } + + static let indent = 2 + static let maxNameWidth = 28 + static let maxVersionWidth = 24 + /// The status is the useful column, so it keeps at least this much before + /// the version column goes (a 50-column terminal shows name + status). + static let minStatusWidth = 24 + + /// The lines to paint, top to bottom: the app rows (at most `maxRows`, + /// see `visibleRows`), then the footer. Every line is at most `width - 1` + /// characters, colour excluded; colour is applied per cell after clipping + /// so an escape sequence is never cut in half. + public static func render(rows: [Row], footer: String, frame: Character?, + width: Int, maxRows: Int, ansi: Ansi) -> [String] { + let usable = max(0, width - 1) + var lines: [String] = [] + + let (shown, hidden) = visibleRows(rows, maxRows: maxRows) + if !shown.isEmpty { + let layout = columns(for: shown, usable: usable) + for row in shown { + lines.append(line(row, layout: layout, ansi: ansi)) + } + } + if !hidden.isEmpty { + let done = hidden.filter { $0.state.isDone }.count + let queued = hidden.filter { $0.state == .queued }.count + var parts: [String] = [] + if done > 0 { parts.append("\(done) done") } + if queued > 0 { parts.append("\(queued) queued") } + let detail = parts.isEmpty ? "" : " (" + parts.joined(separator: ", ") + ")" + let text = String(repeating: " ", count: indent) + "… +\(hidden.count) more" + detail + lines.append(ansi.paint(clip(text, usable), .dim)) + } + + let footerText = frame.map { "\($0) " + footer } ?? footer + lines.append(clip(footerText, usable)) + return lines + } + + /// Which rows fit in `maxRows`. In order, but when there are too many, + /// finished rows are hidden first (their outcome is in the report + /// anyway), then queued rows from the end — so the rows being worked on + /// stay visible. One slot is kept for the "… +N more" line. + public static func visibleRows(_ rows: [Row], maxRows: Int) -> (shown: [Row], hidden: [Row]) { + guard rows.count > maxRows else { return (rows, []) } + let budget = max(0, maxRows - 1) + var keep = Array(rows.indices) + while keep.count > budget { + if let i = keep.firstIndex(where: { rows[$0].state.isDone }) { + keep.remove(at: i) + } else if let i = keep.lastIndex(where: { rows[$0].state == .queued }) { + keep.remove(at: i) + } else { + keep.removeLast() + } + } + let kept = Set(keep) + return (keep.map { rows[$0] }, rows.indices.filter { !kept.contains($0) }.map { rows[$0] }) + } + + // MARK: - Layout + + struct Columns { + var name: Int + var version: Int // 0 = column dropped + var status: Int + } + + /// Column widths for these rows in `usable` characters: the name column + /// fits the longest name (capped), the version column the longest version; + /// the version column goes first when the status would get too narrow, + /// then the name column shrinks. + static func columns(for rows: [Row], usable: Int) -> Columns { + let fixed = indent + 1 + 1 // marker + space + var name = min(maxNameWidth, rows.map { $0.name.count }.max() ?? 0) + var version = min(maxVersionWidth, rows.compactMap { $0.version?.count }.max() ?? 0) + + func status() -> Int { + usable - fixed - name - 2 - (version > 0 ? version + 2 : 0) + } + if version > 0 && status() < minStatusWidth { version = 0 } + if status() < minStatusWidth { + name = max(4, min(name, usable - fixed - 2 - 8)) + } + return Columns(name: name, version: version, status: max(0, status())) + } + + static func line(_ row: Row, layout: Columns, ansi: Ansi) -> String { + let (marker, color) = marker(for: row.state) + let dimmed = row.state == .queued || row.state == .skipped + var out = String(repeating: " ", count: indent) + ansi.paint(String(marker), color) + " " + out += ansi.paint(pad(row.name, layout.name), dimmed ? [.dim] : []) + if layout.version > 0 { + out += " " + ansi.paint(pad(row.version ?? "", layout.version), .dim) + } + if layout.status > 0 { + out += " " + ansi.paint(clip(row.status, layout.status), dimmed ? [.dim] : []) + } + return out + } + + static func clip(_ s: String, _ width: Int) -> String { + if width <= 0 { return "" } + if s.count <= width { return s } + if width == 1 { return "…" } + return String(s.prefix(width - 1)) + "…" + } + + static func pad(_ s: String, _ width: Int) -> String { + let clipped = clip(s, width) + return clipped + String(repeating: " ", count: max(0, width - clipped.count)) + } +} diff --git a/privacycommand/Sources/privacycommandCLIKit/ShellCompletion.swift b/privacycommand/Sources/privacycommandCLIKit/ShellCompletion.swift index bc9ca75..07993aa 100644 --- a/privacycommand/Sources/privacycommandCLIKit/ShellCompletion.swift +++ b/privacycommand/Sources/privacycommandCLIKit/ShellCompletion.swift @@ -96,6 +96,7 @@ public enum ShellCompletion { Option(nil, "--max-risk", "gate: hold casks above this risk for review", value: .choices(riskTiers)), Option(nil, "--min-tier", "only show apps at or above a risk tier", value: .choices(riskTiers)), Option(nil, "--only-noteworthy", "hide apps with nothing noteworthy"), + Option(nil, "--no-color", "disable coloured output"), Option(nil, "--json", "machine-readable JSON"), Option("-h", "--help", "show help"), ] @@ -107,6 +108,7 @@ public enum ShellCompletion { Option(nil, "--greedy", "include casks that normally update themselves"), Option(nil, "--min-tier", "only show apps at or above a risk tier", value: .choices(riskTiers)), Option(nil, "--only-noteworthy", "hide apps with nothing noteworthy"), + Option(nil, "--no-color", "disable coloured output"), Option(nil, "--json", "machine-readable JSON"), Option("-h", "--help", "show help"), ] diff --git a/privacycommand/Sources/privacycommandCLIKit/TUIRenderer.swift b/privacycommand/Sources/privacycommandCLIKit/TUIRenderer.swift index 4bfb7d3..bf3b06e 100644 --- a/privacycommand/Sources/privacycommandCLIKit/TUIRenderer.swift +++ b/privacycommand/Sources/privacycommandCLIKit/TUIRenderer.swift @@ -134,7 +134,9 @@ public enum TUIRenderer { switch m.state(of: app) { case .notStarted, .analyzing: - return ["", colored("Analyzing \(app.name)…", .yellow)] + var lines = ["", colored("Analyzing \(app.name)…", .yellow)] + if let phase = m.phases[app.path] { lines.append(colored(phase, .dim)) } + return lines case .failed(let err): return [ansi.paint(clipPlain(" \(app.name)", width), .bold), "", colored("analysis failed:", .red), plain(" \(err)")] diff --git a/privacycommand/Sources/privacycommandCore/Updates/CaskArtifactFetcher.swift b/privacycommand/Sources/privacycommandCore/Updates/CaskArtifactFetcher.swift index 9aa4243..ed7d1ba 100644 --- a/privacycommand/Sources/privacycommandCore/Updates/CaskArtifactFetcher.swift +++ b/privacycommand/Sources/privacycommandCore/Updates/CaskArtifactFetcher.swift @@ -98,6 +98,20 @@ public enum CaskArtifactFetcher { // MARK: - Scoped download + acquire + guaranteed cleanup + /// What `withDownloadedApp` is doing, for progress display. + public enum Phase: Sendable { + /// `brew fetch` is downloading to `cacheFile` (brew's cache path). + /// Until it finishes, the bytes so far are in `partialFile`. + case downloading(cacheFile: URL) + /// Mounting, extracting or expanding the download to reach the app. + case unpacking + } + + /// Where `brew fetch` keeps an unfinished download of `cacheFile`. + public static func partialFile(for cacheFile: URL) -> URL { + URL(fileURLWithPath: cacheFile.path + ".incomplete") + } + /// Resolve the cask's incoming artifact, expose the `.app` inside it to /// `body`, and tear everything down afterwards — on success or throw. /// Unsupported formats throw `.unsupportedFormat` *before* any download. @@ -107,10 +121,13 @@ public enum CaskArtifactFetcher { /// a download holding several apps yields the matching one. /// - pkgPath: the cask's `pkg` stanza — the installer's path inside a /// `.dmg`/`.zip` — for casks that install with a `.pkg`. + /// - progress: told when the download and the unpacking start; `body` + /// runs once the app is ready. public static func withDownloadedApp( token: String, preferredAppName: String? = nil, pkgPath: String? = nil, + progress: ((Phase) -> Void)? = nil, _ body: (URL) throws -> T ) async throws -> T { guard let brew = HomebrewCaskInventory.brewExecutable() else { throw FetchError.brewNotFound } @@ -132,6 +149,7 @@ public enum CaskArtifactFetcher { // any non-zero exit carrying brew's stderr, so a failed/corrupt // download is reported as .fetchFailed and never analyzed. A // generous wall-clock cap keeps a wedged download from hanging forever. + progress?(.downloading(cacheFile: cacheURL)) do { _ = try HomebrewCaskInventory.runChecked(brew, ["fetch", "--cask", token], timeout: 600) } catch { @@ -144,6 +162,7 @@ public enum CaskArtifactFetcher { // 3. Acquire the .app + a teardown thunk, then run body with guaranteed // cleanup. `defer` can't `await` (DMGMounter.detach is async), so the // do/catch invokes cleanup explicitly on both paths. + progress?(.unpacking) switch format { case .dmg: let mount = try await mount(cacheURL) diff --git a/privacycommand/Sources/privacycommandCore/Updates/HomebrewCaskInventory.swift b/privacycommand/Sources/privacycommandCore/Updates/HomebrewCaskInventory.swift index d4b35d3..6948cfe 100644 --- a/privacycommand/Sources/privacycommandCore/Updates/HomebrewCaskInventory.swift +++ b/privacycommand/Sources/privacycommandCore/Updates/HomebrewCaskInventory.swift @@ -284,18 +284,25 @@ public struct HomebrewCaskInventory: Sendable { /// can't be (with a reason), and the outdated formulae. When not `greedy`, /// also lists the casks only a greedy upgrade would add. Throws /// `.brewNotFound` if `brew` isn't installed. + /// + /// `progress` receives a plain-language label as each step starts — the + /// first can take a while, because `brew outdated` refreshes Homebrew's + /// package index when it's stale. public func outdatedScan( greedy: Bool = false, - appDir: URL = URL(fileURLWithPath: "/Applications") + appDir: URL = URL(fileURLWithPath: "/Applications"), + progress: ((String) -> Void)? = nil ) throws -> OutdatedScan { guard let brew = Self.brewExecutable() else { throw InventoryError.brewNotFound } // One call covers casks and formulae, matching what `brew upgrade` sees. + progress?("Asking Homebrew what's outdated (it may refresh its package index first)") let outdatedJSON = try Self.run(brew, ["outdated", "--json=v2"] + (greedy ? ["--greedy"] : [])) let outdated = try Self.parseOutdated(outdatedJSON) let formulae = Self.parseOutdatedFormulae(outdatedJSON) var greedyOnly: [String] = [] + if !greedy { progress?("Checking for casks that only update with --greedy") } if !greedy, let greedyJSON = try? Self.run(brew, ["outdated", "--cask", "--greedy", "--json=v2"]), let all = try? Self.parseOutdated(greedyJSON) { let regular = Set(outdated.map(\.token)) @@ -307,6 +314,7 @@ public struct HomebrewCaskInventory: Sendable { } // One batched `info` call resolves every install path at once. + progress?("Finding the installed apps for \(outdated.count) outdated cask\(outdated.count == 1 ? "" : "s")") let infoJSON = (try? Self.run(brew, ["info", "--cask", "--json=v2"] + outdated.map(\.token))) ?? Data() let installs = Self.parseInstallKinds(infoJSON, appDir: appDir) diff --git a/privacycommand/Tests/privacycommandCLIKitTests/ProgressBoardTests.swift b/privacycommand/Tests/privacycommandCLIKitTests/ProgressBoardTests.swift new file mode 100644 index 0000000..4c4094d --- /dev/null +++ b/privacycommand/Tests/privacycommandCLIKitTests/ProgressBoardTests.swift @@ -0,0 +1,101 @@ +import XCTest +@testable import privacycommandCLIKit + +/// Covers the pure layout of the live progress board: column widths, what +/// gets dropped on a narrow terminal, which rows are hidden when there are +/// more than fit, and that colour never leaks into a plain run. +final class ProgressBoardTests: XCTestCase { + + private let plain = Ansi(enabled: false) + + private func row(_ name: String, _ state: ProgressBoard.Row.State = .queued, + version: String? = "1.0 → 1.1", status: String = "queued") -> ProgressBoard.Row { + ProgressBoard.Row(id: name, state: state, name: name, version: version, status: status) + } + + func testRowsAlignIntoColumnsWithMarkers() { + let lines = ProgressBoard.render( + rows: [row("Cursor", .working, version: "3.23.12 → 3.24.9", status: "scanning frameworks"), + row("iTerm", .clean, version: "3.7.0 → 3.7.4", status: "low risk 14 · 2 findings"), + row("OpenDisk", version: "1.2.0 → 1.2.7")], + footer: "1 of 3 done · 15s", frame: "⠹", width: 100, maxRows: 10, ansi: plain) + + XCTAssertEqual(lines, [ + " ~ Cursor 3.23.12 → 3.24.9 scanning frameworks", + " ✓ iTerm 3.7.0 → 3.7.4 low risk 14 · 2 findings", + " · OpenDisk 1.2.0 → 1.2.7 queued", + "⠹ 1 of 3 done · 15s", + ]) + } + + func testEveryLineFitsInsideTheWidth() { + let rows = [row("A very long application name indeed", .working, + version: "2026.1.2,abcdef → 2026.1.3,fedcba", + status: "checking code signature & notarization and more words")] + for width in [20, 35, 50, 80] { + let lines = ProgressBoard.render(rows: rows, footer: String(repeating: "x", count: 200), + frame: "⠋", width: width, maxRows: 5, ansi: plain) + for line in lines { + XCTAssertLessThanOrEqual(line.count, width - 1, "width \(width): \(line)") + } + } + } + + func testVersionColumnGoesFirstWhenNarrow() { + let rows = [row("Firefox", .working, version: "155.0.1 → 157.0.1", status: "checking code signature")] + let wide = ProgressBoard.render(rows: rows, footer: "", frame: nil, width: 80, maxRows: 5, ansi: plain) + XCTAssertTrue(wide[0].contains("155.0.1 → 157.0.1")) + + let narrow = ProgressBoard.render(rows: rows, footer: "", frame: nil, width: 40, maxRows: 5, ansi: plain) + XCTAssertFalse(narrow[0].contains("155.0.1"), narrow[0]) + XCTAssertTrue(narrow[0].hasPrefix(" ~ Firefox checking"), narrow[0]) + } + + func testFinishedRowsAreHiddenFirstWhenTooTall() { + let rows = [row("A", .clean, status: "done"), row("B", .working, status: "scanning"), + row("C", .queued), row("D", .queued), row("E", .noteworthy, status: "done")] + let (shown, hidden) = ProgressBoard.visibleRows(rows, maxRows: 3) + // Two slots for rows (one is kept for the "+N more" line): the row + // being worked on survives, then the first queued one. + XCTAssertEqual(shown.map(\.name), ["B", "C"]) + XCTAssertEqual(hidden.map(\.name), ["A", "D", "E"]) + + let lines = ProgressBoard.render(rows: rows, footer: "f", frame: nil, width: 80, maxRows: 3, ansi: plain) + XCTAssertEqual(lines.count, 4) + XCTAssertEqual(lines[2], " … +3 more (2 done, 1 queued)") + } + + func testAllRowsShownWhenTheyFit() { + let rows = [row("A"), row("B")] + let (shown, hidden) = ProgressBoard.visibleRows(rows, maxRows: 2) + XCTAssertEqual(shown.count, 2) + XCTAssertTrue(hidden.isEmpty) + } + + func testFooterOnlyWhenThereAreNoRows() { + let lines = ProgressBoard.render(rows: [], footer: "Asking Homebrew what's outdated · 3s", + frame: "⠋", width: 80, maxRows: 10, ansi: plain) + XCTAssertEqual(lines, ["⠋ Asking Homebrew what's outdated · 3s"]) + } + + func testColourIsAppliedPerCellAndNeverWhenDisabled() { + let rows = [row("iTerm", .clean, status: "low risk")] + let plainLines = ProgressBoard.render(rows: rows, footer: "f", frame: nil, width: 80, maxRows: 5, ansi: plain) + XCTAssertFalse(plainLines.joined().contains("\u{1B}[")) + + let colour = Ansi(enabled: true) + let coloured = ProgressBoard.render(rows: rows, footer: "f", frame: nil, width: 80, maxRows: 5, ansi: colour) + XCTAssertTrue(coloured[0].contains("\u{1B}[32m✓\u{1B}[0m"), coloured[0]) // green marker + XCTAssertFalse(coloured[0].contains("…")) // nothing clipped mid-escape + } + + func testMarkersMatchTheInteractiveBrowser() { + XCTAssertEqual(ProgressBoard.marker(for: .queued).0, "·") + XCTAssertEqual(ProgressBoard.marker(for: .working).0, "~") + XCTAssertEqual(ProgressBoard.marker(for: .downloading).0, "↓") + XCTAssertEqual(ProgressBoard.marker(for: .clean).0, "✓") + XCTAssertEqual(ProgressBoard.marker(for: .noteworthy).0, "⚠") + XCTAssertEqual(ProgressBoard.marker(for: .failed).0, "✗") + XCTAssertEqual(ProgressBoard.marker(for: .skipped).0, "–") + } +} diff --git a/privacycommand/Tests/privacycommandCLIKitTests/ShellCompletionTests.swift b/privacycommand/Tests/privacycommandCLIKitTests/ShellCompletionTests.swift index 5bab275..6443297 100644 --- a/privacycommand/Tests/privacycommandCLIKitTests/ShellCompletionTests.swift +++ b/privacycommand/Tests/privacycommandCLIKitTests/ShellCompletionTests.swift @@ -72,9 +72,9 @@ final class ShellCompletionTests: XCTestCase { .split(separator: "\n", omittingEmptySubsequences: false).map(String.init) XCTAssertEqual(lines.count, 10, "\(lines)") XCTAssertTrue(lines[0].split(separator: " ").contains("preview")) - XCTAssertEqual(lines[1], "--all-apps --apps-dir --fetch --greedy --max-risk --min-tier --only-noteworthy --json --help") + XCTAssertEqual(lines[1], "--all-apps --apps-dir --fetch --greedy --max-risk --min-tier --only-noteworthy --no-color --json --help") XCTAssertEqual(lines[2], "low medium high critical") - XCTAssertEqual(lines[3], "--max-risk --dry-run --no-input --greedy --min-tier --only-noteworthy --json --help") + XCTAssertEqual(lines[3], "--max-risk --dry-run --no-input --greedy --min-tier --only-noteworthy --no-color --json --help") XCTAssertEqual(lines[4], "low medium high critical") XCTAssertEqual(lines[5], "zsh bash fish") XCTAssertEqual(lines[6], "--warnings --warn-exit") diff --git a/privacycommand/Tests/privacycommandCLIKitTests/TUIRendererTests.swift b/privacycommand/Tests/privacycommandCLIKitTests/TUIRendererTests.swift index 8593ca3..83764fd 100644 --- a/privacycommand/Tests/privacycommandCLIKitTests/TUIRendererTests.swift +++ b/privacycommand/Tests/privacycommandCLIKitTests/TUIRendererTests.swift @@ -38,6 +38,19 @@ final class TUIRendererTests: XCTestCase { XCTAssertTrue(f.contains("Analyzing Alpha")) } + func testAnalyzingPhaseShownUnderTheHeading() { + var m = model(["Alpha"]) + m.setState(.analyzing, forPath: "/Applications/Alpha.app") + m.setPhase("Checking code signature & notarization", forPath: "/Applications/Alpha.app") + let f = TUIRenderer.frame(model: m, width: 80, height: 24, ansi: ansi) + XCTAssertTrue(f.contains("Analyzing Alpha")) + XCTAssertTrue(f.contains("Checking code signature & notarization")) + + // The step belongs to the analysis in flight; a result replaces it. + m.setState(.failed("boom"), forPath: "/Applications/Alpha.app") + XCTAssertNil(m.phases["/Applications/Alpha.app"]) + } + func testDoneDetailShowsSections() { var m = model(["Alpha"]) m.setState(.done(snap("Alpha", tier: .high, score: 70, noteworthy: true, diff --git a/privacycommand/Tests/privacycommandCoreTests/CaskArtifactFetcherTests.swift b/privacycommand/Tests/privacycommandCoreTests/CaskArtifactFetcherTests.swift index 7fa6f3b..34bfa05 100644 --- a/privacycommand/Tests/privacycommandCoreTests/CaskArtifactFetcherTests.swift +++ b/privacycommand/Tests/privacycommandCoreTests/CaskArtifactFetcherTests.swift @@ -55,6 +55,15 @@ final class CaskArtifactFetcherTests: XCTestCase { XCTAssertNil(CaskArtifactFetcher.parseCachePath(" \n ".data(using: .utf8)!)) } + // MARK: - partialFile + + func testPartialFileMatchesBrewsIncompleteDownloadName() { + // brew's AbstractFileDownloadStrategy writes to "#{cached_location}.incomplete". + let cache = URL(fileURLWithPath: "/Users/me/Library/Caches/Homebrew/downloads/abc--iTerm2-3_7_4.zip") + XCTAssertEqual(CaskArtifactFetcher.partialFile(for: cache).path, + "/Users/me/Library/Caches/Homebrew/downloads/abc--iTerm2-3_7_4.zip.incomplete") + } + // MARK: - Choosing the app func testPickAppPrefersInstalledNameThenNonUninstaller() {