Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions privacycommand/Sources/privacycommandCLI/AuditCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
259 changes: 195 additions & 64 deletions privacycommand/Sources/privacycommandCLI/PreviewCommand.swift

Large diffs are not rendered by default.

68 changes: 0 additions & 68 deletions privacycommand/Sources/privacycommandCLI/Spinner.swift

This file was deleted.

160 changes: 160 additions & 0 deletions privacycommand/Sources/privacycommandCLI/StatusBoard.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
27 changes: 15 additions & 12 deletions privacycommand/Sources/privacycommandCLI/TerminalDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item>: @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<AuditState>()
private let phases = Inbox<String>()
private let analysisQueue = DispatchQueue(label: "com.privacykey.privacycommand.tui.analysis")

init(model: AppBrowserModel) { self.model = model }
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions privacycommand/Sources/privacycommandCLIKit/AppBrowserModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading