diff --git a/macos/Sources/BurrowStreamReport.swift b/macos/Sources/BurrowStreamReport.swift new file mode 100644 index 0000000..b7d7e45 --- /dev/null +++ b/macos/Sources/BurrowStreamReport.swift @@ -0,0 +1,118 @@ +// +// BurrowStreamReport.swift +// Burrow +// +// Reduces the NDJSON progress feed from `burrow-engine clean --stream` / `optimize --stream` +// into the (groups, summary) shape TaskReportView renders — the same TaskRunReport the old +// human-text `parseTaskReport` produced, so the Clean/Optimize views + completion notification +// are unchanged. The engine emits one JSON object per line: +// +// clean live: {"event":"removed","path":P,"bytes":N} | {"event":"failed",...} | +// {"event":"protected",...} then {"event":"done","freed_bytes":N, +// "freed_human":H,"removed":N,"failed":N,"protected":N} +// clean preview: {"event":"would_remove","path":P,"bytes":N} then {"event":"done", +// "dry_run":true,"would_free_bytes":N,"would_free_human":H,"count":N} +// optimize live: {"event":"task","name":T,"ok":B,"error":E|null} then {"event":"done", +// "ok":B,"tasks":N,"failed":N} +// optimize prev: {"event":"would_run","name":T,"description":D} then {"event":"done", +// "dry_run":true,"tasks":N} +// +// Parsed line-by-line with JSONSerialization (like BurrowEnvelope) — the reduce is called on the +// accumulated `[String]` after every streamed line (throttled) and once at exit. +// + +import Foundation + +enum BurrowStreamReport { + /// Reduce the accumulated NDJSON lines into a TaskRunReport. Unparseable / non-event lines are + /// skipped, so a stray warning on stderr never breaks the result screen. + static func reduce(_ lines: [String]) -> TaskRunReport { + var items: [TaskItem] = [] + var summary: TaskSummary? + + for line in lines { + guard let obj = object(from: line), let event = obj["event"] as? String else { continue } + switch event { + case "removed": + if let p = obj["path"] as? String { items.append(TaskItem(marker: .ok, text: p)) } + case "would_remove": + if let p = obj["path"] as? String { items.append(TaskItem(marker: .action, text: p)) } + case "failed": + if let p = obj["path"] as? String { items.append(TaskItem(marker: .error, text: p)) } + case "protected": + if let p = obj["path"] as? String { items.append(TaskItem(marker: .review, text: p)) } + case "task": + if let name = obj["name"] as? String { + let ok = obj["ok"] as? Bool ?? true + items.append(TaskItem(marker: ok ? .ok : .error, text: name)) + } + case "would_run": + if let name = obj["name"] as? String { items.append(TaskItem(marker: .action, text: name)) } + case "done": + summary = makeSummary(from: obj, itemCount: items.count) + default: + break + } + } + + let groups = items.isEmpty + ? [] + : [TaskGroup(title: NSLocalizedString("Cleanup", comment: "streamed op group title"), + items: items)] + return (groups: groups, summary: summary) + } + + /// A short human label for the HUD detail line, extracted from one NDJSON event. Empty for the + /// terminal `done` (the HUD keeps the last item line) or an unparseable line. + static func hudLine(_ line: String) -> String { + guard let obj = object(from: line), let event = obj["event"] as? String else { return "" } + switch event { + case "removed", "would_remove", "failed", "protected": + if let p = obj["path"] as? String { return (p as NSString).lastPathComponent } + case "task", "would_run": + if let name = obj["name"] as? String { return name } + default: + break + } + return "" + } + + // MARK: - internals + + private static func object(from line: String) -> [String: Any]? { + let t = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard t.first == "{", let data = t.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return obj + } + + /// Build the summary from a `done` event, covering both the live and preview shapes of clean + /// and optimize. `itemCount` is the number of accumulated item lines (fallback when a specific + /// count field is absent). + private static func makeSummary(from done: [String: Any], itemCount: Int) -> TaskSummary { + // Freed disk (live clean) vs would-free (preview) vs no size (optimize). + let freedHuman = done["freed_human"] as? String + let wouldFreeHuman = done["would_free_human"] as? String + let space = freedHuman ?? wouldFreeHuman ?? "" + + // Item count: removed (clean live) → count (clean preview) → tasks (optimize) → accumulated. + let count = intField(done, "removed") + ?? intField(done, "count") + ?? intField(done, "tasks") + ?? itemCount + let items = count > 0 ? String(count) : "" + + // Live clean prints the actual freed size → surface it as the freed-space number. + let freeChange = freedHuman ?? "" + + return TaskSummary(space: space, items: items, categories: "", freeChange: freeChange, freeNow: "") + } + + /// Read an integer field regardless of whether JSONSerialization bridged it to Int or Double. + private static func intField(_ obj: [String: Any], _ key: String) -> Int? { + if let n = obj[key] as? Int { return n } + if let d = obj[key] as? Double { return Int(d) } + return nil + } +} diff --git a/macos/Sources/MoleCLI.swift b/macos/Sources/MoleCLI.swift index 0da2ab7..b484a4c 100644 --- a/macos/Sources/MoleCLI.swift +++ b/macos/Sources/MoleCLI.swift @@ -77,8 +77,10 @@ enum MoleCLI { /// over any system `mo`: users run our engine with zero install and never touch upstream /// (GPL-relicensed) mo. It's part of the signed app bundle, so it's a trusted location. static func bundledExecutable() -> String? { - guard let url = Bundle.main.url(forResource: "mole", withExtension: nil, - subdirectory: "engine") else { return nil } + // The single bundled `burrow-engine` binary, staged as Resources/burrow (bundle-burrow.sh). + // It replaced the old Resources/engine/mole digger — one binary that does the work AND + // speaks the envelope. Same file BurrowConductor.executableURL resolves. + guard let url = Bundle.main.url(forResource: "burrow", withExtension: nil) else { return nil } return FileManager.default.isExecutableFile(atPath: url.path) ? url.path : nil } diff --git a/macos/Sources/OperationFlow.swift b/macos/Sources/OperationFlow.swift index 1243155..5b16890 100644 --- a/macos/Sources/OperationFlow.swift +++ b/macos/Sources/OperationFlow.swift @@ -291,9 +291,11 @@ extension ToolOperation where Report == TaskRunReport { static func moleStream(_ args: [String], gate: Gate = .none, elevated: Bool = false, label: String?, notifyOnEnd: Bool = false) -> ToolOperation { + // The bundled engine streams NDJSON (clean/optimize --stream); reduce those events into the + // same (groups, summary) shape the human-text parser produced. See BurrowStreamReport. ToolOperation(label: label, arguments: args, gate: gate, elevated: elevated, - reduce: { parseTaskReport($0) }, - hudLine: { TaskReportText.line($0) }, + reduce: { BurrowStreamReport.reduce($0) }, + hudLine: { BurrowStreamReport.hudLine($0) }, notifyOnEnd: notifyOnEnd, finalDetail: { $0.summary?.completionLine ?? "" }) } diff --git a/macos/Tests/BurrowStreamReportTests.swift b/macos/Tests/BurrowStreamReportTests.swift new file mode 100644 index 0000000..c91fbb4 --- /dev/null +++ b/macos/Tests/BurrowStreamReportTests.swift @@ -0,0 +1,74 @@ +// +// BurrowStreamReportTests.swift +// BurrowTests +// +// Locks the NDJSON → TaskRunReport contract that OperationFlow uses for streamed +// clean/optimize. The engine emits one JSON object per line; the reducer must build the +// same (groups, summary) shape the views render. +// + +import XCTest +@testable import Burrow + +final class BurrowStreamReportTests: XCTestCase { + func testCleanPreview_yieldsCleanedSummary() { + let lines = [ + #"{"event":"would_remove","path":"/Users/x/Library/Caches/npm","bytes":201129000}"#, + #"{"event":"done","dry_run":true,"would_free_bytes":402438000,"would_free_human":"383.8MB","count":372}"#, + ] + let report = BurrowStreamReport.reduce(lines) + XCTAssertEqual(report.groups.count, 1) + XCTAssertEqual(report.groups.first?.items.count, 1) + XCTAssertEqual(report.summary?.space, "383.8MB") + XCTAssertEqual(report.summary?.items, "372") + // No freeChange on a preview → "Cleaned", not "Freed". + XCTAssertEqual(report.summary?.completionLine, "Cleaned 383.8MB · 372 items") + } + + func testCleanLive_yieldsFreedSummary() { + let lines = [ + #"{"event":"removed","path":"/a/x","bytes":10}"#, + #"{"event":"failed","path":"/a/y","error":"denied"}"#, + #"{"event":"protected","path":"/a/keep"}"#, + #"{"event":"done","freed_bytes":2048,"freed_human":"2.0KB","removed":1,"failed":1,"protected":1}"#, + ] + let report = BurrowStreamReport.reduce(lines) + XCTAssertEqual(report.groups.first?.items.count, 3, "removed + failed + protected are shown") + // A live done carries freed_human → freeChange → "Freed". + XCTAssertEqual(report.summary?.completionLine, "Freed 2.0KB · 1 items") + } + + func testOptimize_taskEvents() { + let lines = [ + #"{"event":"task","name":"flush_dns","ok":true,"error":null}"#, + #"{"event":"task","name":"restart_dock","ok":false,"error":"no proc"}"#, + #"{"event":"done","ok":false,"tasks":2,"failed":1}"#, + ] + let report = BurrowStreamReport.reduce(lines) + XCTAssertEqual(report.groups.first?.items.count, 2) + XCTAssertEqual(report.summary?.items, "2", "task count") + } + + func testGarbageLinesAreIgnored() { + // A stray non-JSON line (e.g. a warning) must not break the reduce. + let lines = [ + "warning: something on stderr", + #"{"event":"removed","path":"/a","bytes":1}"#, + "", + #"{"event":"done","freed_bytes":1,"freed_human":"1B","removed":1,"failed":0,"protected":0}"#, + ] + let report = BurrowStreamReport.reduce(lines) + XCTAssertEqual(report.groups.first?.items.count, 1) + XCTAssertNotNil(report.summary) + } + + func testHudLine_extractsReadableLabel() { + XCTAssertEqual( + BurrowStreamReport.hudLine(#"{"event":"removed","path":"/a/b/npm cache","bytes":1}"#), + "npm cache") + XCTAssertEqual( + BurrowStreamReport.hudLine(#"{"event":"task","name":"flush_dns","ok":true}"#), + "flush_dns") + XCTAssertEqual(BurrowStreamReport.hudLine("not json"), "") + } +} diff --git a/macos/Tests/OperationFlowTests.swift b/macos/Tests/OperationFlowTests.swift index 39363d7..eef1b02 100644 --- a/macos/Tests/OperationFlowTests.swift +++ b/macos/Tests/OperationFlowTests.swift @@ -38,6 +38,7 @@ final class OperationFlowTests: XCTestCase { } /// Canned dry-run output in mo's report shape (parseTaskReport-compatible). + // The human-text output the `cleanOp()` fixture's `parseTaskReport` reducer consumes. static let cannedClean: [ProcessEvent] = [ .line("➤ Developer tools"), .line(" → npm cache, 191.8MB"), @@ -45,6 +46,16 @@ final class OperationFlowTests: XCTestCase { .exited(0), ] + // The engine's `clean --stream` NDJSON preview, which the `moleStream(...)` op's + // BurrowStreamReport reducer consumes. A dry-run done carries `would_free_human` → + // summary.space (no freeChange), so the completion line reads "Cleaned 383.8MB · 372 items" + // — same summary the old human-text "Potential space" preview produced. + static let cannedCleanStream: [ProcessEvent] = [ + .line(#"{"event":"would_remove","path":"/Users/x/Library/Caches/npm cache","bytes":201129000}"#), + .line(#"{"event":"done","dry_run":true,"would_free_bytes":402438000,"would_free_human":"383.8MB","count":372}"#), + .exited(0), + ] + typealias CleanReport = (groups: [TaskGroup], summary: TaskSummary?) static func cleanOp(gate: ToolOperation.Gate = .none, @@ -166,7 +177,7 @@ final class OperationFlowTests: XCTestCase { // and the parsed summary replaces the last streamed line as the final // detail — that's the body a completion notification carries. func testNotifyOnEnd_andFinalDetail_reachOperationCenter() async throws { - let port = FakeProcessPort(script: Self.cannedClean) + let port = FakeProcessPort(script: Self.cannedCleanStream) let center = OperationCenter() let flow = OperationFlow(process: port, hasFullDiskAccess: { true }, resolveMo: { _ in "/usr/local/bin/mo" }, center: center) diff --git a/macos/project.yml b/macos/project.yml index 906fdef..64aae8b 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -94,43 +94,28 @@ targets: - name: Bundle burrow-engine (MIT) basedOnDependencyAnalysis: false script: | - # Stage the MIT engine into the app's Resources so users run our engine with zero - # install (never upstream GPL mo). Activates when the engine source is present — - # a release/CI vendors it (vendor/burrow-engine) or sets BURROW_ENGINE_SRC; dev - # builds without it fall back to a system-installed engine. + # Build the ONE MIT `burrow-engine` binary and stage it as Resources/burrow — it does all + # the work natively AND emits the stable Burrow envelope + NDJSON streaming, so it replaces + # both the old burrow-cli conductor and the burrow-digger engine (a single binary, no + # separate engine dir). Activates when a burrow-engine checkout is present — a release/CI + # vendors it (vendor/burrow-engine) or sets BURROW_ENGINE_SRC; dev builds without it fall + # back to a system-installed engine. # - # NOTE: Xcode's CodeSign phase runs AFTER all build phases, so it re-signs the app - # over the freshly-added engine and the resource seal ends up stale. The final seal - # therefore happens POST-build: use scripts/build.sh locally, and the release - # pipeline's inside-out re-sign (#178) seals it for distribution. + # NOTE: Xcode's CodeSign phase runs AFTER all build phases, so it re-signs the app over the + # freshly-added binary and the resource seal ends up stale. The final seal therefore + # happens POST-build: use scripts/build.sh locally, and the release pipeline's inside-out + # re-sign (#178) seals it for distribution. ENGINE_SRC="${BURROW_ENGINE_SRC:-$SRCROOT/vendor/burrow-engine}" - # Require a real engine checkout, not just the directory: a plain - # (no-submodule) checkout — e.g. the PR `ci` job — leaves an EMPTY - # vendor/burrow-engine dir for the gitlink, so `[ -d ]` alone would be - # true and bundle-engine.sh would then fail building from missing - # source. Gate on mole + cmd/ so we bundle only a populated checkout - # and otherwise fall through to the system engine (build still green). - if [ -f "$ENGINE_SRC/mole" ] && [ -d "$ENGINE_SRC/cmd" ]; then - "$SRCROOT/scripts/bundle-engine.sh" "$ENGINE_SRC" "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" + # Require a real engine checkout, not just the gitlink dir: a plain (no-submodule) + # checkout — e.g. the PR `ci` job — leaves an EMPTY vendor/burrow-engine dir, so `[ -d ]` + # alone would be true and the cargo build would fail from missing source. Gate on + # Cargo.toml + src/ + cargo so we bundle only a populated checkout, and otherwise fall + # through to the system engine (build still green). + if [ -f "$ENGINE_SRC/Cargo.toml" ] && [ -d "$ENGINE_SRC/src" ] && command -v cargo >/dev/null 2>&1; then + "$SRCROOT/scripts/bundle-burrow.sh" "$ENGINE_SRC" "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" \ + || echo "warning: burrow-engine build failed — Burrow falls back to a system engine." else - echo "warning: burrow-engine source not present at $ENGINE_SRC — Burrow falls back to a system engine. Set BURROW_ENGINE_SRC or init the vendor/burrow-engine submodule." - fi - - name: Bundle burrow (conductor) - basedOnDependencyAnalysis: false - script: | - # Stage the FSL `burrow` conductor beside the MIT engine so the GUI can shell out to - # `burrow --json` (stable envelope + NDJSON streaming) instead of the engine - # directly. Gated on a POPULATED vendor/burrow-cli checkout: a plain (no-submodule) - # checkout — e.g. the PR `ci` job — leaves an EMPTY gitlink dir, so we bundle only when - # Cargo.toml is really present and otherwise fall through to the direct engine (build - # still green, exactly like the engine phase above). Final resource seal happens - # POST-build (scripts/build.sh locally; the release pipeline's inside-out re-sign). - BURROW_CLI_SRC="${BURROW_CLI_SRC:-$SRCROOT/vendor/burrow-cli}" - if [ -f "$BURROW_CLI_SRC/Cargo.toml" ] && [ -d "$BURROW_CLI_SRC/src" ] && command -v cargo >/dev/null 2>&1; then - "$SRCROOT/scripts/bundle-burrow.sh" "$BURROW_CLI_SRC" "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" \ - || echo "warning: burrow conductor build failed — Burrow falls back to the direct engine." - else - echo "warning: burrow-cli source or cargo not present at $BURROW_CLI_SRC — Burrow falls back to the direct engine. Set BURROW_CLI_SRC or init the vendor/burrow-cli submodule." + echo "warning: burrow-engine source or cargo not present at $ENGINE_SRC — Burrow falls back to a system engine. Set BURROW_ENGINE_SRC or init the vendor/burrow-engine submodule." fi - name: Bundle fclones (MIT sidecar) basedOnDependencyAnalysis: false diff --git a/macos/scripts/bundle-burrow.sh b/macos/scripts/bundle-burrow.sh index daa6baa..3fff88e 100755 --- a/macos/scripts/bundle-burrow.sh +++ b/macos/scripts/bundle-burrow.sh @@ -1,32 +1,35 @@ #!/usr/bin/env bash # -# bundle-burrow.sh — stage the `burrow` CONDUCTOR binary into the app's Resources. +# bundle-burrow.sh — build the `burrow-engine` binary and stage it as the app's `Resources/burrow`. # -# The GUI shells out to this bundled conductor (`burrow --json`) instead of the engine -# directly, so it gets the stable Burrow envelope + NDJSON streaming contract with zero install. -# burrow locates its engine via $BURROW_ENGINE_DIR, which the app sets to the sibling-bundled -# Resources/engine (see bundle-engine.sh). The FSL-licensed conductor and the MIT engine stay -# arm's-length (separate processes); only the built binary travels — no Rust source. +# The GUI shells out to this ONE bundled binary (`burrow --json`) for the stable Burrow +# envelope, and `clean --stream` / `optimize --stream` for the live NDJSON progress feed. The new +# MIT `burrow-engine` does all the work natively (analyze/status/clean/optimize/uninstall/net/ +# orphans/slim-check/evict/dupes/photos/history/purge/installer), so there is no separate engine +# dir or conductor anymore — this single binary replaces both the old burrow-cli conductor and the +# burrow-digger engine. It's still staged under the name `burrow` so the app's resolution +# (BurrowConductor.executableURL → Resources/burrow) is unchanged. Only the built binary travels — +# no Rust source ships. `dupes` shells the sibling-bundled Resources/fclones via $BURROW_FCLONES. # -# Usage: bundle-burrow.sh -# BURROW_CLI_SRC a burrow-cli checkout (has Cargo.toml, src/) -# RESOURCES_DIR the app bundle's Resources dir (burrow is written directly inside it) +# Usage: bundle-burrow.sh +# BURROW_ENGINE_SRC a burrow-engine checkout (has Cargo.toml with the `burrow-engine` binary) +# RESOURCES_DIR the app bundle's Resources dir (the binary is written directly inside it) set -euo pipefail -SRC="${1:?burrow-cli source dir required}" +SRC="${1:?burrow-engine source dir required}" RESOURCES="${2:?resources dir required}" OUT="$RESOURCES/burrow" command -v cargo >/dev/null 2>&1 || { - echo "error: cargo not found — cannot build the burrow conductor (install Rust, or omit the vendor/burrow-cli submodule to fall back to the system engine)" + echo "error: cargo not found — cannot build burrow-engine (install Rust, or omit the vendor/burrow-engine submodule to fall back to a system engine)" exit 1 } -# Build UNIVERSAL (arm64 + x86_64) so the conductor runs on BOTH Apple Silicon and Intel Macs. -# An arch-only binary hangs the universal app on the other arch (same lesson as the engine's Go -# binaries, issue #221). Rust cross-compiles per target; we add both targets (rustup fetches the -# missing slice) and lipo them together. GIT_TERMINAL_PROMPT=0 + /dev/null 2>&1; then rustup target add "$A" "$X" >/dev/null 2>&1 || true fi - cargo build --release --target "$A" /dev/null \ || codesign --force --sign - --timestamp=none "$OUT" || true -echo "bundled burrow -> $OUT ($(lipo -archs "$OUT" 2>/dev/null || echo native); signed with '${IDENTITY}')" +echo "bundled burrow-engine -> $OUT ($(lipo -archs "$OUT" 2>/dev/null || echo native); signed with '${IDENTITY}')"