diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index deb6cf1..d0197a7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -96,7 +96,7 @@ jobs:
xcrun llvm-cov export \
-format=lcov \
-instr-profile "$BIN/codecov/default.profdata" \
- "$BIN/lifsaverPackageTests.xctest/Contents/MacOS/lifsaverPackageTests" \
+ "$BIN/LifsaverPackageTests.xctest/Contents/MacOS/LifsaverPackageTests" \
-ignore-filename-regex='(Tests|\.build)/' > coverage.lcov
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
@@ -124,7 +124,7 @@ jobs:
run: |
./scripts/release/bundle_app.sh
plutil -lint dist/Lifsaver.app/Contents/Info.plist
- test -x dist/Lifsaver.app/Contents/Helpers/lifsaver
+ test -x dist/Lifsaver.app/Contents/MacOS/Lifsaver
- name: Package installer
run: ./scripts/release/make_pkg.sh
- name: Upload installer for per-OS install tests
@@ -169,16 +169,15 @@ jobs:
path: dist
- name: Install pkg
run: sudo installer -pkg dist/lifsaver_installer_macos.pkg -target /
- - name: Verify installed layout, signature, and CLI
+ - name: Verify installed layout, signature, and version
run: |
echo "→ $(sw_vers -productVersion) $(uname -m)"
test -d /Applications/Lifsaver.app
- test -L /usr/local/bin/lifsaver
/usr/bin/codesign --verify --strict /Applications/Lifsaver.app
VERSION="$(./scripts/version.sh)"
- INSTALLED="$(/usr/local/bin/lifsaver --version)"
+ INSTALLED="$(plutil -extract CFBundleShortVersionString raw /Applications/Lifsaver.app/Contents/Info.plist)"
if [ "$INSTALLED" != "$VERSION" ]; then
- echo "ERROR: installed CLI reports $INSTALLED, expected $VERSION" >&2
+ echo "ERROR: installed app reports $INSTALLED, expected $VERSION" >&2
exit 1
fi
- name: Launch app and verify it stays up
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2b87d62..a2a9b96 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -120,15 +120,14 @@ jobs:
run: |
brew tap lucuma13/dit
brew install --cask lucuma13/dit/lifsaver
- - name: Smoke test CLI and app bundle
+ - name: Smoke test
env:
VERSION: ${{ needs.build-and-upload.outputs.version }}
run: |
- echo "→ lifsaver --version"
- INSTALLED="$(lifsaver --version)"
+ test -d /Applications/Lifsaver.app
+ /usr/bin/codesign --verify --strict /Applications/Lifsaver.app
+ INSTALLED="$(plutil -extract CFBundleShortVersionString raw /Applications/Lifsaver.app/Contents/Info.plist)"
if [ "$INSTALLED" != "$VERSION" ]; then
- echo "ERROR: installed CLI reports $INSTALLED, expected $VERSION" >&2
+ echo "ERROR: installed app reports $INSTALLED, expected $VERSION" >&2
exit 1
fi
- test -d /Applications/Lifsaver.app
- /usr/bin/codesign --verify --strict /Applications/Lifsaver.app
diff --git a/.gitignore b/.gitignore
index ecb71b7..1f82b52 100644
--- a/.gitignore
+++ b/.gitignore
@@ -113,8 +113,7 @@ fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
-# Local Xcode/SwiftPM user state. Package.resolved is committed, so the
-# template's blanket ".swiftpm" suggestion is not used as-is.
+# Local Xcode/SwiftPM user state.
.swiftpm/
DerivedData/
diff --git a/.swiftlint.yml b/.swiftlint.yml
index d05f638..2a16c8c 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -6,6 +6,11 @@ included:
line_length:
warning: 120
ignores_urls: true
+# --strict promotes warnings to errors, so raise the warning threshold itself.
+file_length:
+ warning: 1000
+type_body_length:
+ warning: 350
identifier_name:
excluded: [ok, fs] # mount-outcome case/counters; filesystem-type local
disabled_rules:
diff --git a/Package.resolved b/Package.resolved
deleted file mode 100644
index 9a4da92..0000000
--- a/Package.resolved
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "originHash" : "860cf30570b0c9aa59a63ff19048728aa93848b25f008273069bd48ed774b542",
- "pins" : [
- {
- "identity" : "swift-argument-parser",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/apple/swift-argument-parser",
- "state" : {
- "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382",
- "version" : "1.8.2"
- }
- }
- ],
- "version" : 3
-}
diff --git a/Package.swift b/Package.swift
index e496e7a..8eb5131 100644
--- a/Package.swift
+++ b/Package.swift
@@ -2,31 +2,17 @@
import PackageDescription
let package = Package(
- name: "lifsaver",
+ name: "Lifsaver",
platforms: [.macOS(.v13)],
- dependencies: [
- .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0")
- ],
targets: [
- .target(name: "LifsaverCore"),
+ .target(name: "LifsaverKit"),
.executableTarget(
- name: "lifsaver",
- dependencies: [
- "LifsaverCore",
- .product(name: "ArgumentParser", package: "swift-argument-parser"),
- ]
- ),
- .executableTarget(
- name: "LifsaverApp",
- dependencies: ["LifsaverCore"]
- ),
- .testTarget(
- name: "LifsaverCoreTests",
- dependencies: ["LifsaverCore"]
+ name: "Lifsaver",
+ dependencies: ["LifsaverKit"]
),
.testTarget(
- name: "LifsaverCLITests",
- dependencies: ["lifsaver"]
+ name: "LifsaverKitTests",
+ dependencies: ["LifsaverKit"]
),
]
)
diff --git a/README.md b/README.md
index 69158af..8d99d90 100644
--- a/README.md
+++ b/README.md
@@ -4,15 +4,13 @@
[](https://github.com/lucuma13/lifsaver/actions/workflows/ci.yml)
[](https://codecov.io/gh/lucuma13/lifsaver)
-
+
-`lifsaver` addresses a macOS bug on LIFS (Live Image File System) which prevents multiple cards from mounting when they have the same name (e.g. "Untitled"). It lives in your menu bar and watches for cards that appear but never mount, when that happens you can force-mount the stalled card with two clicks. Please note that whereas this utility has been fully tested, you should use it at your own risk.
+`lifsaver` addresses a macOS bug on LIFS (Live Image File System) which prevents multiple cards from mounting when they have the same name (e.g. "Untitled"). It watches for cards that appear but never mount, and let's you force mount them with two clicks. Please note that whereas this utility has been fully tested, you should use it at your own risk.
-
-
-
### 🚀 Installation
@@ -24,26 +22,9 @@ brew install --cask lucuma13/dit/lifsaver
The installer is not notarized – macOS will warn on first open: right-click the .pkg → Open, or allow it under System Settings → Privacy & Security.
-### 📖 Usage
-
-**Menu bar app**. The app watches disk activity and flags any volume that appeared but never mounted:
-
-- The app icon turns orange and a *"Stalled volume detected"* notification appears. Click it, enter your administrator password in the standard macOS dialog, and the cards appear in Finder.
-- The menu shows the same state at any time: *"Mount 2 stalled volumes"* when cards are stuck, *"No stalled volumes detected"* otherwise.
-
-**CLI** — same engine, same safety interlocks:
-
-```bash
-lifsaver # scan (read-only), confirm, then mount via sudo
-lifsaver --verbose # show the full mount sequence and raw mount errors
-```
-
### 🐞 Reporting bugs
-If `lifsaver` misses a stalled card or fails to mount one, please send a diagnostic report:
-
-- **Menu bar app** — choose *Save Diagnostic Report…* from the menu, optionally describe what happened, save the file, then click *Email Report* and send it to me (or open a [GitHub issue](https://github.com/lucuma13/lifsaver/issues/new)).
-- **CLI** — run `lifsaver report`; it saves the report to your `~/Downloads` folder, then email it to me (or open a [GitHub issue](https://github.com/lucuma13/lifsaver/issues/new)).
+If the app misses a stalled card or fails to mount it, please send a diagnostic report: choose *Send Diagnostic Report* from the menu, optionally describe what happened, save the file, then click *Email Report* and send it to me (or open a [GitHub issue](https://github.com/lucuma13/lifsaver/issues/new)).
### ⚠️ Disclaimer
diff --git a/Sources/LifsaverApp/AppDelegate.swift b/Sources/Lifsaver/AppDelegate.swift
similarity index 100%
rename from Sources/LifsaverApp/AppDelegate.swift
rename to Sources/Lifsaver/AppDelegate.swift
diff --git a/Sources/LifsaverApp/DiagnosticReportFlow.swift b/Sources/Lifsaver/DiagnosticReportFlow.swift
similarity index 94%
rename from Sources/LifsaverApp/DiagnosticReportFlow.swift
rename to Sources/Lifsaver/DiagnosticReportFlow.swift
index d5ce319..c92a2f7 100644
--- a/Sources/LifsaverApp/DiagnosticReportFlow.swift
+++ b/Sources/Lifsaver/DiagnosticReportFlow.swift
@@ -1,12 +1,12 @@
import AppKit
-import LifsaverCore
+import LifsaverKit
/// UI flow behind the "Save Diagnostic Report…" menu item: ask for an
/// optional description, pick a destination, then gather the report and
/// reveal the saved file in Finder.
@MainActor
enum DiagnosticReportFlow {
- static func begin(appEvents: [String]) {
+ static func begin(appEvents: [String], liveLog: [String]) {
// A menu bar app is never frontmost; without activating, the alert
// and save panel open behind whatever the user is working in.
NSApp.activate(ignoringOtherApps: true)
@@ -20,9 +20,9 @@ enum DiagnosticReportFlow {
Task {
let reporter = DiagnosticsReporter(runner: DefaultProcessRunner())
- let report = await reporter.generate(userNote: note, appEvents: appEvents)
+ let report = await reporter.generate(userNote: note, appEvents: appEvents, liveLog: liveLog)
do {
- try report.write(to: url, atomically: true, encoding: .utf8)
+ try report.jsonString().write(to: url, atomically: true, encoding: .utf8)
NSWorkspace.shared.activateFileViewerSelecting([url])
offerEmail(for: url)
} catch {
diff --git a/Sources/LifsaverApp/DiskActivityWatcher.swift b/Sources/Lifsaver/DiskActivityWatcher.swift
similarity index 100%
rename from Sources/LifsaverApp/DiskActivityWatcher.swift
rename to Sources/Lifsaver/DiskActivityWatcher.swift
diff --git a/Sources/Lifsaver/EscalatedMount.swift b/Sources/Lifsaver/EscalatedMount.swift
new file mode 100644
index 0000000..ffad37e
--- /dev/null
+++ b/Sources/Lifsaver/EscalatedMount.swift
@@ -0,0 +1,204 @@
+import Foundation
+import LifsaverKit
+import os
+
+/// Mounts stalled volumes in two passes, escalating only when it buys
+/// something, then re-runs the app's own binary as root through the standard
+/// macOS administrator password dialog (`do shell script … with administrator
+/// privileges`) for whatever the unprivileged pass could not reach. The helper
+/// invocation (marked by `EscalatedMountHelper.helperFlag`) scans and mounts as
+/// root, reporting back as JSON on stdout.
+///
+/// `do shell script` swallows the inner command's exit status (a nonzero exit
+/// raises an AppleScript error instead), so the inner shell always exits 0 and
+/// appends a `__EXIT:)" in an osascript error
+ /// message, or nil. Parsing the code instead of pinning one rendered string
+ /// keeps cancel detection working if the message text around the code ever
+ /// changes.
+ private static func appleScriptErrorCode(from message: String) -> Int? {
+ guard
+ message.hasSuffix(")"),
+ let open = message.lastIndex(of: "(")
+ else { return nil }
+ return Int(message[message.index(after: open).. String? {
+ guard let attributes = try? FileManager.default.attributesOfItem(atPath: path) else {
+ return "cannot inspect helper binary at \(path)"
+ }
+ guard attributes[.type] as? FileAttributeType == .typeRegular else {
+ return "helper binary at \(path) is not a regular file"
+ }
+ let owner = (attributes[.ownerAccountID] as? NSNumber)?.uint32Value
+ guard owner == 0 || owner == getuid() else {
+ return "helper binary at \(path) is owned by another user"
+ }
+ let permissions = (attributes[.posixPermissions] as? NSNumber)?.int16Value ?? 0
+ guard permissions & 0o022 == 0 else {
+ return "helper binary at \(path) is writable by other users"
+ }
+ return nil
+ }
+
+ private static func parse(
+ stdout: String, helperStderr: String
+ ) -> (outcome: EscalatedMountOutcome, helperLog: [String]) {
+ guard let sentinelRange = stdout.range(of: sentinel, options: .backwards) else {
+ return (.error(annotate("missing exit sentinel in helper output", withStderr: helperStderr)), [])
+ }
+ let exitText = stdout[sentinelRange.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines)
+ let payload = String(stdout[..` (enforced by publish.yml).
+private let githubRepo = "lucuma13/lifsaver"
+private let installerAssetName = "lifsaver_installer_macos.pkg"
+
+/// `UpdateChecker.start()` is cache-gated to at most one request per day;
+/// re-arming it on this cadence keeps a launch-at-login instance that runs for
+/// weeks discovering releases, instead of only ever checking at launch.
+private let updateRecheckInterval: TimeInterval = 6 * 60 * 60
+
+/// User preferences for auto updates and start at login.
+private let automaticUpdatesDefaultsKey = "AutomaticUpdatesEnabled"
+private let loginItemDefaultAppliedKey = "DidApplyLoginItemDefault"
+
+/// Owns the status-bar item and its menu. A DiskArbitration watcher rescans
+/// (read-only) whenever disks appear, disappear, mount, or unmount, so
+/// stalled cards are flagged proactively: the icon turns orange and a
+/// clickable notification is posted. Opening the menu shows the latest
+/// results immediately and reconciles with a fresh scan. What the menu says
+/// is decided by `StatusMenuModel`; this class only renders it.
+@MainActor
+final class StatusItemController: NSObject, NSMenuDelegate {
+ private let statusItem: NSStatusItem
+ private let menu = NSMenu()
+ private let scanner = DiskScanner(runner: DefaultProcessRunner(), console: quietConsole)
+ private let updateChecker = UpdateChecker(
+ package: "lifsaver",
+ repo: githubRepo,
+ currentVersion: lifsaverVersion
+ )
+ private var updateRecheckTimer: Timer?
+
+ /// Monotonic token invalidating in-flight scans when a newer one starts.
+ private var scanGeneration = 0
+ private var mountInProgress = false
+
+ private var lastScan: StatusMenuModel.ScanState?
+ private var stalledWatch = StalledWatchState()
+ private var diskWatcher: DiskActivityWatcher?
+ /// Re-entrancy guard for the manual check: the menu closes on click, so a
+ /// user could reopen it and click again while a fetch is still in flight.
+ private var checkingForUpdates = false
+ /// The "Checking for Updates…" spinner, shown only if a manual check
+ /// outlasts its grace period. nil when no check is slow enough to warrant it.
+ private var updateProgress: UpdateProgressWindow?
+ private var menuIsOpen = false
+ /// SMAppService.status is a blocking launchd XPC round-trip; query it when
+ /// the menu opens or the toggle flips, not on every rebuild.
+ private var launchAtLoginEnabled = false
+
+ /// Recent scan/mount outcomes, embedded in diagnostic reports.
+ private var recentEvents: [String] = []
+
+ private let baseIcon: NSImage?
+ private let attentionIcon: NSImage?
+
+ override init() {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
+
+ // Prefer the bundled lifsaver lifebuoy; fall back to the closest SF
+ // Symbol when running unbundled (e.g. `swift run` during development).
+ let base =
+ NSImage(named: "MenuBarIcon")
+ ?? NSImage(
+ systemSymbolName: "lifepreserver",
+ accessibilityDescription: "lifsaver"
+ )
+ base?.isTemplate = true
+ baseIcon = base
+ // The alert artwork is pre-rendered (see scripts/render/icons.sh) because turning
+ // it 45° here would resample an 18px bitmap and soften it. Deliberately
+ // not a template image — the orange is the signal, so the system must
+ // not tint it away. Unbundled runs have no PNG to load and fall back to
+ // punching a dot out of whatever base we ended up with.
+ if let alert = NSImage(named: "MenuBarIconAlert") {
+ alert.isTemplate = false
+ attentionIcon = alert
+ } else {
+ attentionIcon = base.map(Self.attentionVariant(of:))
+ }
+
+ super.init()
+
+ menu.delegate = self
+ statusItem.menu = menu
+ refreshIcon()
+
+ // Auto updates and start-at-login are both opt-out.
+ UserDefaults.standard.register(defaults: [automaticUpdatesDefaultsKey: true])
+ applyDefaultLoginItemIfNeeded()
+
+ Notifier.installClickHandler { [weak self] in self?.startMount() }
+ // Registration replays all present disks, which triggers the first scan.
+ diskWatcher = DiskActivityWatcher { [weak self] in self?.startScan() }
+
+ if automaticUpdatesEnabled {
+ updateChecker.start()
+ }
+ let checker = updateChecker
+ let timer = Timer(timeInterval: updateRecheckInterval, repeats: true) { _ in
+ guard UserDefaults.standard.bool(forKey: automaticUpdatesDefaultsKey) else { return }
+ checker.start()
+ }
+ timer.tolerance = updateRecheckInterval / 4
+ RunLoop.main.add(timer, forMode: .common)
+ updateRecheckTimer = timer
+ }
+
+ // MARK: - NSMenuDelegate
+
+ func menuWillOpen(_ menu: NSMenu) {
+ menuIsOpen = true
+ guard !mountInProgress else { return }
+ launchAtLoginEnabled = SMAppService.mainApp.status == .enabled
+ // The watcher keeps `lastScan` current, so show it straight away and
+ // let the reconciling scan mutate the menu in place if anything moved.
+ rebuildMenu()
+ startScan()
+ }
+
+ func menuDidClose(_ menu: NSMenu) {
+ menuIsOpen = false
+ }
+
+ // MARK: - Scanning
+
+ private func startScan() {
+ scanGeneration += 1
+ let generation = scanGeneration
+
+ // The scan is async (subprocess waits suspend rather than block), so
+ // it can run as a main-actor task without freezing the menu.
+ let scanner = self.scanner
+ Task { [weak self] in
+ do {
+ let devices = try await scanner.scanTargets()
+ // The per-device queries are independent — fan them out instead
+ // of paying one subprocess round-trip after another.
+ let fsTypes = await withTaskGroup(of: (Int, String).self) { group in
+ for (index, device) in devices.enumerated() {
+ group.addTask { (index, await scanner.partitionFSType(device)) }
+ }
+ var results = [String](repeating: "", count: devices.count)
+ for await (index, fsType) in group {
+ results[index] = fsType
+ }
+ return results
+ }
+ let targets = zip(devices, fsTypes).map { StatusMenuModel.ScanTarget(devId: $0, fsType: $1) }
+ // A card diskarbitrationd is still fsck-ing may yet mount on
+ // its own — don't call it stalled. One pgrep snapshot serves
+ // every device; per-device freshness only matters at mount
+ // time, where Mounter re-checks.
+ let fsckListing = await scanner.fsckListing()
+ let settled = devices.filter { !scanner.isFsckActive($0, listing: fsckListing) }
+ self?.finishScan(
+ .results(targets),
+ settled: settled,
+ fsckPending: settled.count != devices.count,
+ generation: generation
+ )
+ } catch {
+ NSLog("lifsaver scan failed: %@", "\(error)")
+ self?.logEvent("scan failed: \(error)")
+ self?.finishScan(.failed, settled: [], fsckPending: false, generation: generation)
+ }
+ }
+ }
+
+ private func finishScan(
+ _ state: StatusMenuModel.ScanState,
+ settled: [String],
+ fsckPending: Bool,
+ generation: Int
+ ) {
+ guard generation == scanGeneration else { return }
+ lastScan = state
+
+ // A failed scan proves nothing about the disks; leave the stalled
+ // state (and badge) as they were.
+ if case .results = state {
+ let newlyStalled = stalledWatch.update(stalled: settled)
+ if let body = StatusMenuModel.stalledNotificationBody(newCount: newlyStalled.count) {
+ Notifier.post(title: "lifsaver", body: body, category: Notifier.stalledVolumeCategory)
+ }
+ refreshIcon()
+ }
+ // Every open rebuilds anyway; while the menu is closed only the icon
+ // needs to stay current.
+ if menuIsOpen {
+ rebuildMenu()
+ }
+
+ // fsck can finish without producing a disk event; look again shortly.
+ if fsckPending {
+ diskWatcher?.poke(after: fsckRetryDelay)
+ }
+ }
+
+ // MARK: - Icon
+
+ private func refreshIcon() {
+ guard let button = statusItem.button else { return }
+ let attention = stalledWatch.hasStalled
+ button.image = (attention ? attentionIcon : baseIcon) ?? baseIcon
+ button.toolTip =
+ attention
+ ? "lifsaver — stalled volume detected"
+ : "lifsaver — mount stalled camera cards"
+ }
+
+ /// The menu bar icon with an alert dot in the lower-right corner, punched
+ /// out of the artwork so the dot stays legible at menu bar size. Still a
+ /// template image: the system supplies the colour.
+ private static func attentionVariant(of base: NSImage) -> NSImage {
+ let image = NSImage(size: base.size, flipped: false) { rect in
+ base.draw(in: rect)
+ let dotSide = rect.width * 0.4
+ let dot = NSRect(x: rect.maxX - dotSide, y: 0, width: dotSide, height: dotSide)
+ NSColor.black.setFill()
+ NSGraphicsContext.current?.compositingOperation = .destinationOut
+ NSBezierPath(ovalIn: dot.insetBy(dx: -rect.width * 0.08, dy: -rect.width * 0.08)).fill()
+ NSGraphicsContext.current?.compositingOperation = .sourceOver
+ NSBezierPath(ovalIn: dot).fill()
+ return true
+ }
+ image.isTemplate = true
+ return image
+ }
+
+ // MARK: - Menu construction
+
+ private func rebuildMenu() {
+ menu.removeAllItems()
+
+ let entries = StatusMenuModel.entries(
+ state: lastScan ?? .scanning,
+ newerVersion: updateChecker.knownNewerVersion(),
+ showLaunchAtLogin: Bundle.main.bundleIdentifier != nil,
+ launchAtLoginEnabled: launchAtLoginEnabled,
+ automaticUpdatesEnabled: automaticUpdatesEnabled
+ )
+ for entry in entries {
+ menu.addItem(menuItem(for: entry))
+ }
+ }
+
+ // MARK: - Actions
+
+ @objc private func mountClicked() {
+ startMount()
+ }
+
+ /// Shared by the menu item and notification clicks.
+ private func startMount() {
+ guard !mountInProgress else { return }
+ mountInProgress = true
+
+ // The unprivileged pass runs in-process; only if it leaves something
+ // unmounted does this suspend on the password dialog, whose runner does
+ // its blocking pipe reads on GCD, off the main actor.
+ let scanner = self.scanner
+ Task { [weak self] in
+ let outcome = await EscalatedMount.run(scanner: scanner)
+ self?.finishMount(outcome)
+ }
+ }
+
+ private func finishMount(_ outcome: EscalatedMount.Outcome) {
+ mountInProgress = false
+ // The root pass ran in another process; its lines arrive in-band,
+ // already timestamped, and slot into the live log here.
+ if !outcome.helperLog.isEmpty {
+ liveLog.append(["--- escalated helper (root) ---"] + outcome.helperLog)
+ }
+ // Logged separately from the escalated pass: whether a password was
+ // needed at all is exactly what a mount bug report turns on.
+ logEvent(StatusMenuModel.unprivilegedMountEventLine(for: outcome.unprivileged))
+ if let escalated = outcome.escalated {
+ if case .error(let message) = escalated {
+ NSLog("lifsaver mount error: %@", message)
+ }
+ logEvent(StatusMenuModel.mountEventLine(for: escalated))
+ }
+
+ let combined = StatusMenuModel.combinedOutcome(
+ unprivileged: outcome.unprivileged, escalated: outcome.escalated)
+ if let body = StatusMenuModel.notificationBody(for: combined) {
+ Notifier.post(title: "lifsaver", body: body)
+ }
+ // Successful mounts announce themselves through DiskArbitration; this
+ // covers failures, so the badge and menu still tell the truth.
+ diskWatcher?.poke()
+ }
+
+ @objc private func saveReportClicked() {
+ DiagnosticReportFlow.begin(appEvents: recentEvents, liveLog: liveLog.snapshot())
+ }
+
+ /// Keeps the last few outcomes with timestamps; old entries roll off.
+ private func logEvent(_ line: String) {
+ recentEvents.append("\(ISO8601DateFormatter().string(from: Date())) \(line)")
+ if recentEvents.count > 20 {
+ recentEvents.removeFirst(recentEvents.count - 20)
+ }
+ }
+
+ /// Manual "Check for Updates". Refetches now, bypassing the daily cache. A
+ /// newer version also updates the menu item for the next time it opens.
+ @objc private func checkForUpdatesClicked() {
+ guard !checkingForUpdates else { return }
+ checkingForUpdates = true
+
+ // The fetch is usually sub-second but can run to the 5s timeout on a bad
+ // network. Reveal a spinner only once it outstays this grace period, so
+ // a fast check never flashes a window the user barely registers.
+ let reveal = Task { [weak self] in
+ try? await Task.sleep(for: .milliseconds(500))
+ guard !Task.isCancelled, let self else { return }
+ let window = UpdateProgressWindow()
+ self.updateProgress = window
+ window.show()
+ }
+
+ Task { [weak self] in
+ guard let self else { return }
+ let outcome = await updateChecker.checkNow()
+ reveal.cancel()
+ updateProgress?.close()
+ updateProgress = nil
+ checkingForUpdates = false
+ rebuildMenu()
+ presentManualCheckOutcome(outcome)
+ }
+ }
+
+ /// Alert acknowledging a user-initiated update check. Confirm when up to
+ /// date, offer the download when a newer version exists, and explain a
+ /// failed check plainly.
+ private func presentManualCheckOutcome(_ outcome: ManualCheckOutcome) {
+ // A menu bar app is never frontmost; without activating, the alert
+ // opens behind whatever the user is working in.
+ NSApp.activate(ignoringOtherApps: true)
+
+ let alert = NSAlert()
+ switch outcome {
+ case .updateAvailable(let version):
+ alert.messageText = "Update available!"
+ alert.informativeText =
+ "lifsaver \(version) is available now (you have \(lifsaverVersion) installed)."
+ alert.addButton(withTitle: "Download…")
+ alert.addButton(withTitle: "Later")
+ if alert.runModal() == .alertFirstButtonReturn {
+ downloadLatestInstaller()
+ }
+ case .upToDate:
+ alert.messageText = "You're up to date."
+ alert.informativeText = "lifsaver \(lifsaverVersion) is the latest version."
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ case .failed:
+ alert.messageText = "Update error!"
+ alert.informativeText =
+ "Please check your internet connection and try again."
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ }
+ }
+
+ /// Downloads the installer for exactly the version the menu item named. Not
+ /// the `releases/latest/download/…` alias: GitHub resolves that to the
+ /// newest non-prerelease release, which 404s while only prereleases exist
+ /// and can serve a different version than the item advertised.
+ @objc private func downloadLatestInstaller() {
+ guard
+ let version = updateChecker.knownNewerVersion(),
+ let url = URL(
+ string: "https://github.com/\(githubRepo)/releases/download/v\(version)/\(installerAssetName)"
+ )
+ else { return }
+ NSWorkspace.shared.open(url)
+ }
+
+ @objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) {
+ do {
+ if SMAppService.mainApp.status == .enabled {
+ try SMAppService.mainApp.unregister()
+ } else {
+ try SMAppService.mainApp.register()
+ }
+ } catch {
+ NSLog("lifsaver: launch-at-login toggle failed: %@", "\(error)")
+ }
+ launchAtLoginEnabled = SMAppService.mainApp.status == .enabled
+ // Update the checkmark in place; rebuilding would dismiss the submenu.
+ sender.state = launchAtLoginEnabled ? .on : .off
+ }
+
+ /// Auto updates are opt-out.
+ private var automaticUpdatesEnabled: Bool {
+ UserDefaults.standard.bool(forKey: automaticUpdatesDefaultsKey)
+ }
+
+ @objc private func toggleAutomaticUpdates(_ sender: NSMenuItem) {
+ let enabled = !automaticUpdatesEnabled
+ UserDefaults.standard.set(enabled, forKey: automaticUpdatesDefaultsKey)
+ // Re-enabling should look now, not wait for the next recheck tick.
+ if enabled {
+ updateChecker.start()
+ }
+ sender.state = enabled ? .on : .off
+ }
+
+ /// Start at login: enable on first launch, then leave untouched.
+ private func applyDefaultLoginItemIfNeeded() {
+ guard Bundle.main.bundleIdentifier != nil else { return }
+ let defaults = UserDefaults.standard
+ guard !defaults.bool(forKey: loginItemDefaultAppliedKey) else { return }
+ defaults.set(true, forKey: loginItemDefaultAppliedKey)
+ do {
+ if SMAppService.mainApp.status != .enabled {
+ try SMAppService.mainApp.register()
+ }
+ } catch {
+ NSLog("lifsaver: default launch-at-login registration failed: %@", "\(error)")
+ }
+ }
+
+ @objc private func quit() {
+ NSApp.terminate(nil)
+ }
+}
+
+// MARK: - Menu entry rendering
+
+extension StatusItemController {
+ fileprivate func menuItem(for entry: StatusMenuModel.Entry) -> NSMenuItem {
+ switch entry {
+ case .separator:
+ return NSMenuItem.separator()
+ case .disabled(let title):
+ let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
+ item.isEnabled = false
+ return item
+ case .mount(let title):
+ return actionItem(title: title, action: #selector(mountClicked))
+ case .checkForUpdates(let title):
+ return actionItem(title: title, action: #selector(checkForUpdatesClicked))
+ case .updateAvailable(let title):
+ return actionItem(title: title, action: #selector(downloadLatestInstaller))
+ case .moreOptions(let showStartAtLogin, let startAtLoginEnabled, let automaticUpdatesEnabled):
+ let item = NSMenuItem(title: "Settings", action: nil, keyEquivalent: "")
+ let submenu = NSMenu()
+ if showStartAtLogin {
+ let login = actionItem(title: "Start at Login", action: #selector(toggleLaunchAtLogin))
+ login.state = startAtLoginEnabled ? .on : .off
+ submenu.addItem(login)
+ }
+ let auto = actionItem(
+ title: "Automatically Check for Updates", action: #selector(toggleAutomaticUpdates))
+ auto.state = automaticUpdatesEnabled ? .on : .off
+ submenu.addItem(auto)
+ item.submenu = submenu
+ return item
+ case .saveReport(let title):
+ return actionItem(title: title, action: #selector(saveReportClicked))
+ case .quit(let title):
+ return actionItem(title: title, action: #selector(quit), keyEquivalent: "q")
+ }
+ }
+
+ private func actionItem(title: String, action: Selector, keyEquivalent: String = "") -> NSMenuItem {
+ let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent)
+ item.target = self
+ return item
+ }
+}
+
+/// "Checking for Updates…" window shown while a slow manual check is in flight,
+/// dismissed the moment it returns.
+@MainActor
+final class UpdateProgressWindow {
+ private let window: NSWindow
+
+ init() {
+ let padding: CGFloat = 20
+ let iconSide: CGFloat = 64
+ let gap: CGFloat = 16
+ let textWidth: CGFloat = 240
+ let width = padding + iconSide + gap + textWidth + padding
+ let height = padding + iconSide + padding
+ let content = NSView(frame: NSRect(x: 0, y: 0, width: width, height: height))
+
+ // The same app icon NSAlert draws, so the progress and result screens
+ // share their branding.
+ let icon = NSImageView(
+ frame: NSRect(x: padding, y: height - padding - iconSide, width: iconSide, height: iconSide))
+ icon.image = NSApp.applicationIconImage
+ icon.imageScaling = .scaleProportionallyUpOrDown
+ content.addSubview(icon)
+
+ let textX = padding + iconSide + gap
+ let title = NSTextField(labelWithString: "Checking for Updates…")
+ title.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
+ title.frame = NSRect(x: textX, y: height - padding - 20, width: textWidth, height: 20)
+ content.addSubview(title)
+
+ let bar = NSProgressIndicator(
+ frame: NSRect(x: textX, y: height - padding - 52, width: textWidth, height: 20))
+ bar.style = .bar
+ bar.isIndeterminate = true
+ bar.startAnimation(nil)
+ content.addSubview(bar)
+
+ // No .closable/.miniaturizable: the window has no manual dismissal —
+ // it lives exactly as long as the fetch.
+ window = NSWindow(
+ contentRect: content.frame, styleMask: [.titled], backing: .buffered, defer: false)
+ window.title = "lifsaver"
+ window.contentView = content
+ window.isReleasedWhenClosed = false
+ window.center()
+ }
+
+ func show() {
+ NSApp.activate(ignoringOtherApps: true)
+ window.makeKeyAndOrderFront(nil)
+ }
+
+ func close() {
+ window.orderOut(nil)
+ }
+}
diff --git a/Sources/LifsaverApp/main.swift b/Sources/Lifsaver/main.swift
similarity index 58%
rename from Sources/LifsaverApp/main.swift
rename to Sources/Lifsaver/main.swift
index ec6fa95..0dfc82b 100644
--- a/Sources/LifsaverApp/main.swift
+++ b/Sources/Lifsaver/main.swift
@@ -1,5 +1,10 @@
import AppKit
+// When relaunched under the admin password dialog this process is the root
+// mount helper, not the app: run the mount sequence and exit before any
+// AppKit setup.
+EscalatedMount.exitIfHelperInvocation()
+
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
diff --git a/Sources/LifsaverApp/EscalatedMount.swift b/Sources/LifsaverApp/EscalatedMount.swift
deleted file mode 100644
index 067f60c..0000000
--- a/Sources/LifsaverApp/EscalatedMount.swift
+++ /dev/null
@@ -1,88 +0,0 @@
-import Foundation
-import LifsaverCore
-
-/// Runs the bundled CLI as root through the standard macOS administrator
-/// password dialog (`do shell script … with administrator privileges`).
-///
-/// `do shell script` swallows the inner command's exit status (a nonzero exit
-/// raises an AppleScript error instead), so the inner shell always exits 0 and
-/// appends a `__EXIT:` sentinel that carries the real status back.
-enum EscalatedMount {
- private static let sentinel = "__EXIT:"
- /// AppleScript's userCanceledErr — osascript reports the error code as a
- /// suffix: `execution error: User canceled. (-128)`.
- private static let userCancelledSuffix = "(-128)"
-
- static func run(runner: any ProcessRunning = DefaultProcessRunner()) async -> EscalatedMountOutcome {
- guard let cliPath = locateCLI() else { return .cliNotFound }
-
- // Quote for the shell, then escape the whole line for the AppleScript
- // string literal (backslashes first, then double quotes).
- let shellQuotedPath = "'" + cliPath.replacingOccurrences(of: "'", with: "'\\''") + "'"
- let innerCommand = "\(shellQuotedPath) --json 2>/dev/null; printf '\\n\(sentinel)%d' \"$?\""
- let appleScriptBody =
- innerCommand
- .replacingOccurrences(of: "\\", with: "\\\\")
- .replacingOccurrences(of: "\"", with: "\\\"")
- let script = "do shell script \"\(appleScriptBody)\" with administrator privileges"
-
- let result: ProcessResult
- do {
- // Infinite timeout: the user may leave the password dialog open
- // indefinitely. The runner drains both pipes concurrently off the
- // cooperative pool, so nothing blocks while the dialog waits.
- result = try await runner.run("/usr/bin/osascript", ["-e", script], timeout: .infinity)
- } catch {
- return .error("could not launch osascript: \(error)")
- }
-
- if result.status != 0 {
- let message = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
- if message.hasSuffix(userCancelledSuffix) {
- return .cancelled
- }
- return .error(message)
- }
-
- return parse(stdout: result.stdoutText)
- }
-
- private static func parse(stdout: String) -> EscalatedMountOutcome {
- guard let sentinelRange = stdout.range(of: sentinel, options: .backwards) else {
- return .error("missing exit sentinel in CLI output")
- }
- let exitText = stdout[sentinelRange.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines)
- let payload = String(stdout[.. String? {
- let executable = URL(fileURLWithPath: Bundle.main.executablePath ?? CommandLine.arguments[0])
- let candidates = [
- executable
- .deletingLastPathComponent() // Contents/MacOS
- .deletingLastPathComponent() // Contents
- .appendingPathComponent("Helpers/lifsaver"),
- executable
- .deletingLastPathComponent()
- .appendingPathComponent("lifsaver"),
- ]
- for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate.path) {
- return candidate.path
- }
- return nil
- }
-}
diff --git a/Sources/LifsaverApp/MountCoordinator.swift b/Sources/LifsaverApp/MountCoordinator.swift
deleted file mode 100644
index 129c085..0000000
--- a/Sources/LifsaverApp/MountCoordinator.swift
+++ /dev/null
@@ -1,53 +0,0 @@
-import LifsaverCore
-
-/// Mounts stalled volumes in two passes, so the password dialog only appears
-/// when it buys something.
-///
-/// `diskutil mount` mounts external removable media as the logged-in user, so
-/// the first pass runs in-process with no privileges and no prompt. Only if it
-/// leaves a volume unmounted — the raw `/sbin/mount_*` fallback needs root — is
-/// the bundled CLI re-run under the admin dialog, which rescans as root and
-/// picks up whatever is left.
-enum MountCoordinator {
- struct Outcome: Sendable {
- var unprivileged: CLIReport.Counts
- /// nil when the first pass left nothing for root to do — the case where
- /// the user is never asked for a password at all.
- var escalated: EscalatedMountOutcome?
- }
-
- static func run(scanner: DiskScanner) async -> Outcome {
- let targets: [String]
- do {
- targets = try await scanner.scanTargets()
- } catch {
- // Escalating would only reach the same failing scan under root, so
- // report it rather than spend a password dialog on it.
- return Outcome(unprivileged: .init(), escalated: .error("scan failed: \(error)"))
- }
-
- let counts = await mountUnprivileged(targets, scanner: scanner)
- guard counts.fail > 0 else { return Outcome(unprivileged: counts, escalated: nil) }
- return Outcome(unprivileged: counts, escalated: await EscalatedMount.run())
- }
-
- /// First pass: diskutil only, no escalation. A `.fail` here means "needs
- /// root", not "impossible".
- private static func mountUnprivileged(
- _ targets: [String], scanner: DiskScanner
- ) async -> CLIReport.Counts {
- let mounter = Mounter(scanner: scanner, allowRawFallback: false)
- var counts = CLIReport.Counts()
- for devId in targets {
- switch await mounter.execute(devId) {
- case .ok:
- counts.ok += 1
- case .fail:
- counts.fail += 1
- case .skip:
- counts.skip += 1
- }
- }
- return counts
- }
-}
diff --git a/Sources/LifsaverApp/StatusItemController.swift b/Sources/LifsaverApp/StatusItemController.swift
deleted file mode 100644
index ac9368c..0000000
--- a/Sources/LifsaverApp/StatusItemController.swift
+++ /dev/null
@@ -1,343 +0,0 @@
-import AppKit
-import LifsaverCore
-import ServiceManagement
-
-/// Diagnostics from the core land in the log, never in a terminal that
-/// doesn't exist.
-private let quietConsole = Console(
- out: { NSLog("lifsaver: %@", $0) },
- err: { NSLog("lifsaver: %@", $0) }
-)
-
-/// diskarbitrationd can keep fsck running for a while on a dirty card; while
-/// it does, the card may still mount on its own. Re-check on this cadence
-/// instead of calling the card stalled.
-private let fsckRetryDelay: TimeInterval = 15
-
-/// Owns the status-bar item and its menu. A DiskArbitration watcher rescans
-/// (read-only) whenever disks appear, disappear, mount, or unmount, so
-/// stalled cards are flagged proactively: the icon turns orange and a
-/// clickable notification is posted. Opening the menu shows the latest
-/// results immediately and reconciles with a fresh scan. What the menu says
-/// is decided by `StatusMenuModel`; this class only renders it.
-@MainActor
-final class StatusItemController: NSObject, NSMenuDelegate {
- private let statusItem: NSStatusItem
- private let menu = NSMenu()
- private let scanner = DiskScanner(runner: DefaultProcessRunner(), console: quietConsole)
- private let updateChecker = UpdateChecker(
- package: "lifsaver",
- repo: "lucuma13/lifsaver",
- currentVersion: lifsaverVersion,
- upgradeCommand: lifsaverUpgradeCommand(),
- // The app has no meaningful tty; the checker is used only as a data
- // source for the "Update available" menu item.
- stderrIsInteractive: { true }
- )
-
- /// Invalidates in-flight scans when a newer one starts.
- private var scanGeneration = ScanGeneration()
- private var mountInProgress = false
-
- private var lastScan: StatusMenuModel.ScanState?
- private var stalledWatch = StalledWatchState()
- private var diskWatcher: DiskActivityWatcher?
- private var checkingForUpdates = false
-
- /// Recent scan/mount outcomes, embedded in diagnostic reports.
- private var recentEvents: [String] = []
-
- private let baseIcon: NSImage?
- private let attentionIcon: NSImage?
-
- override init() {
- statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
-
- // Prefer the bundled lifsaver lifebuoy; fall back to the closest SF
- // Symbol when running unbundled (e.g. `swift run` during development).
- let base =
- NSImage(named: "MenuBarIcon")
- ?? NSImage(
- systemSymbolName: "lifepreserver",
- accessibilityDescription: "lifsaver"
- )
- base?.isTemplate = true
- baseIcon = base
- // The alert artwork is pre-rendered (see scripts/render/icons.sh) because turning
- // it 45° here would resample an 18px bitmap and soften it. Deliberately
- // not a template image — the orange is the signal, so the system must
- // not tint it away. Unbundled runs have no PNG to load and fall back to
- // punching a dot out of whatever base we ended up with.
- if let alert = NSImage(named: "MenuBarIconAlert") {
- alert.isTemplate = false
- attentionIcon = alert
- } else {
- attentionIcon = base.map(Self.attentionVariant(of:))
- }
-
- super.init()
-
- menu.delegate = self
- statusItem.menu = menu
- refreshIcon()
-
- Notifier.installClickHandler { [weak self] in self?.startMount() }
- // Registration replays all present disks, which triggers the first scan.
- diskWatcher = DiskActivityWatcher { [weak self] in self?.startScan() }
-
- updateChecker.start()
- }
-
- // MARK: - NSMenuDelegate
-
- func menuWillOpen(_ menu: NSMenu) {
- guard !mountInProgress else { return }
- // The watcher keeps `lastScan` current, so show it straight away and
- // let the reconciling scan mutate the menu in place if anything moved.
- rebuildMenu(state: lastScan ?? .scanning)
- startScan()
- }
-
- // MARK: - Scanning
-
- private func startScan() {
- let generation = scanGeneration.begin()
-
- // The scan is async (subprocess waits suspend rather than block), so
- // it can run as a main-actor task without freezing the menu.
- let scanner = self.scanner
- Task { [weak self] in
- do {
- let devices = try await scanner.scanTargets()
- var targets: [StatusMenuModel.ScanTarget] = []
- var settled: [String] = []
- for device in devices {
- targets.append(
- StatusMenuModel.ScanTarget(
- devId: device,
- fsType: await scanner.partitionFSType(device)
- )
- )
- // A card diskarbitrationd is still fsck-ing may yet mount
- // on its own — don't call it stalled.
- if !(await scanner.isFsckActive(device)) {
- settled.append(device)
- }
- }
- self?.finishScan(
- .results(targets),
- settled: settled,
- fsckPending: settled.count != devices.count,
- generation: generation
- )
- } catch {
- NSLog("lifsaver scan failed: %@", "\(error)")
- self?.logEvent("scan failed: \(error)")
- self?.finishScan(.failed, settled: [], fsckPending: false, generation: generation)
- }
- }
- }
-
- private func finishScan(
- _ state: StatusMenuModel.ScanState,
- settled: [String],
- fsckPending: Bool,
- generation: Int
- ) {
- guard scanGeneration.isCurrent(generation) else { return }
- lastScan = state
-
- // A failed scan proves nothing about the disks; leave the stalled
- // state (and badge) as they were.
- if case .results = state {
- let newlyStalled = stalledWatch.update(stalled: settled)
- if let body = StatusMenuModel.stalledNotificationBody(newCount: newlyStalled.count) {
- Notifier.post(title: "lifsaver", body: body, category: Notifier.stalledVolumeCategory)
- }
- refreshIcon()
- }
- rebuildMenu(state: state)
-
- // fsck can finish without producing a disk event; look again shortly.
- if fsckPending {
- diskWatcher?.poke(after: fsckRetryDelay)
- }
- }
-
- // MARK: - Icon
-
- private func refreshIcon() {
- guard let button = statusItem.button else { return }
- let attention = stalledWatch.hasStalled
- button.image = (attention ? attentionIcon : baseIcon) ?? baseIcon
- button.toolTip =
- attention
- ? "lifsaver — stalled volume detected"
- : "lifsaver — mount stalled camera cards"
- }
-
- /// The menu bar icon with an alert dot in the lower-right corner, punched
- /// out of the artwork so the dot stays legible at menu bar size. Still a
- /// template image: the system supplies the colour.
- private static func attentionVariant(of base: NSImage) -> NSImage {
- let image = NSImage(size: base.size, flipped: false) { rect in
- base.draw(in: rect)
- let dotSide = rect.width * 0.4
- let dot = NSRect(x: rect.maxX - dotSide, y: 0, width: dotSide, height: dotSide)
- NSColor.black.setFill()
- NSGraphicsContext.current?.compositingOperation = .destinationOut
- NSBezierPath(ovalIn: dot.insetBy(dx: -rect.width * 0.08, dy: -rect.width * 0.08)).fill()
- NSGraphicsContext.current?.compositingOperation = .sourceOver
- NSBezierPath(ovalIn: dot).fill()
- return true
- }
- image.isTemplate = true
- return image
- }
-
- // MARK: - Menu construction
-
- private func rebuildMenu(state: StatusMenuModel.ScanState) {
- menu.removeAllItems()
-
- let entries = StatusMenuModel.entries(
- state: state,
- newerVersion: updateChecker.knownNewerVersion(),
- isCheckingForUpdates: checkingForUpdates,
- showLaunchAtLogin: Bundle.main.bundleIdentifier != nil,
- launchAtLoginEnabled: SMAppService.mainApp.status == .enabled
- )
- for entry in entries {
- menu.addItem(menuItem(for: entry))
- }
- }
-
- private func menuItem(for entry: StatusMenuModel.Entry) -> NSMenuItem {
- switch entry {
- case .separator:
- return NSMenuItem.separator()
- case .disabled(let title):
- let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
- item.isEnabled = false
- return item
- case .mount(let title):
- return actionItem(title: title, action: #selector(mountClicked))
- case .checkForUpdates(let title):
- return actionItem(title: title, action: #selector(checkForUpdatesClicked))
- case .updateAvailable(let title):
- return actionItem(title: title, action: #selector(downloadLatestInstaller))
- case .launchAtLogin(let enabled):
- let item = actionItem(title: "Start at Login", action: #selector(toggleLaunchAtLogin))
- item.state = enabled ? .on : .off
- return item
- case .saveReport(let title):
- return actionItem(title: title, action: #selector(saveReportClicked))
- case .quit(let title):
- return actionItem(title: title, action: #selector(quit), keyEquivalent: "q")
- }
- }
-
- private func actionItem(title: String, action: Selector, keyEquivalent: String = "") -> NSMenuItem {
- let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent)
- item.target = self
- return item
- }
-
- // MARK: - Actions
-
- @objc private func mountClicked() {
- startMount()
- }
-
- /// Shared by the menu item and notification clicks.
- private func startMount() {
- guard !mountInProgress else { return }
- mountInProgress = true
-
- // The unprivileged pass runs in-process; only if it leaves something
- // unmounted does this suspend on the password dialog, whose runner does
- // its blocking pipe reads on GCD, off the main actor.
- let scanner = self.scanner
- Task { [weak self] in
- let outcome = await MountCoordinator.run(scanner: scanner)
- self?.finishMount(outcome)
- }
- }
-
- private func finishMount(_ outcome: MountCoordinator.Outcome) {
- mountInProgress = false
- // Logged separately from the escalated pass: whether a password was
- // needed at all is exactly what a mount bug report turns on.
- logEvent(StatusMenuModel.unprivilegedMountEventLine(for: outcome.unprivileged))
- if let escalated = outcome.escalated {
- if case .error(let message) = escalated {
- NSLog("lifsaver mount error: %@", message)
- }
- logEvent(StatusMenuModel.mountEventLine(for: escalated))
- }
-
- let combined = StatusMenuModel.combinedOutcome(
- unprivileged: outcome.unprivileged, escalated: outcome.escalated)
- if let body = StatusMenuModel.notificationBody(for: combined) {
- Notifier.post(title: "lifsaver", body: body)
- }
- // Successful mounts announce themselves through DiskArbitration; this
- // covers failures, so the badge and menu still tell the truth.
- diskWatcher?.poke()
- }
-
- @objc private func saveReportClicked() {
- DiagnosticReportFlow.begin(appEvents: recentEvents)
- }
-
- /// Keeps the last few outcomes with timestamps; old entries roll off.
- private func logEvent(_ line: String) {
- recentEvents.append("\(ISO8601DateFormatter().string(from: Date())) \(line)")
- if recentEvents.count > 20 {
- recentEvents.removeFirst(recentEvents.count - 20)
- }
- }
-
- /// Manual "Check for Updates". Refetches now, bypassing the daily cache,
- /// and swaps the item to "Update to version …" if the check turns one up.
- @objc private func checkForUpdatesClicked() {
- guard !checkingForUpdates else { return }
- checkingForUpdates = true
- rebuildMenu(state: lastScan ?? .scanning)
-
- Task { [weak self] in
- guard let self else { return }
- await updateChecker.checkNow()
- checkingForUpdates = false
- rebuildMenu(state: lastScan ?? .scanning)
- }
- }
-
- /// Downloads the latest installer directly rather than sending the user to
- /// the releases page. The asset name is frozen and matches the CLI's hint.
- @objc private func downloadLatestInstaller() {
- let url = URL(
- string:
- "https://github.com/lucuma13/lifsaver/releases/latest/download/lifsaver_installer_macos.pkg"
- )
- if let url {
- NSWorkspace.shared.open(url)
- }
- }
-
- @objc private func toggleLaunchAtLogin() {
- do {
- if SMAppService.mainApp.status == .enabled {
- try SMAppService.mainApp.unregister()
- } else {
- try SMAppService.mainApp.register()
- }
- } catch {
- NSLog("lifsaver: launch-at-login toggle failed: %@", "\(error)")
- }
- }
-
- @objc private func quit() {
- NSApp.terminate(nil)
- }
-}
diff --git a/Sources/LifsaverCore/Console.swift b/Sources/LifsaverCore/Console.swift
deleted file mode 100644
index 1916bb1..0000000
--- a/Sources/LifsaverCore/Console.swift
+++ /dev/null
@@ -1,18 +0,0 @@
-import Foundation
-
-/// Injectable output sink so callers (CLI, app, tests) decide where user-facing
-/// text lands. Mirrors stdout/stderr by default.
-public struct Console: Sendable {
- public var out: @Sendable (String) -> Void
- public var err: @Sendable (String) -> Void
-
- public init(out: @escaping @Sendable (String) -> Void, err: @escaping @Sendable (String) -> Void) {
- self.out = out
- self.err = err
- }
-
- public static let standard = Console(
- out: { print($0) },
- err: { FileHandle.standardError.write(Data(($0 + "\n").utf8)) }
- )
-}
diff --git a/Sources/LifsaverCore/DiagnosticsReport.swift b/Sources/LifsaverCore/DiagnosticsReport.swift
deleted file mode 100644
index 4085fbc..0000000
--- a/Sources/LifsaverCore/DiagnosticsReport.swift
+++ /dev/null
@@ -1,160 +0,0 @@
-import Foundation
-import os
-
-#if arch(arm64)
- private let buildArchitecture = "arm64"
-#elseif arch(x86_64)
- private let buildArchitecture = "x86_64"
-#else
- private let buildArchitecture = "unknown"
-#endif
-
-/// Where diagnostic reports should be emailed. Assembled at runtime from
-/// fragments so it never appears verbatim in the repository, on rendered
-/// GitHub pages, or in the shipped binary's string table.
-public let lifsaverSupportEmail = ["alterluigi", "+", "debug", "@", "gma", "il", ".", "com"].joined()
-
-/// Pre-addressed mailto: draft for sending a report. mailto cannot attach
-/// files, so the body asks the sender to attach the saved report themselves.
-public func lifsaverReportMailtoURL(reportFilename: String) -> URL? {
- var components = URLComponents()
- components.scheme = "mailto"
- components.path = lifsaverSupportEmail
- components.queryItems = [
- URLQueryItem(name: "subject", value: "lifsaver diagnostic report"),
- URLQueryItem(name: "body", value: "Please attach \(reportFilename) to this email before sending.\n\n"),
- ]
- return components.url
-}
-
-/// Assembles the plain-text diagnostic report.
-///
-/// Read-only: it re-runs the scanner verbosely and captures the raw
-/// `diskutil` plists the scanner decides from, so a missed or unmountable
-/// card can be replayed from the report alone. Generation never throws —
-/// a report about a failure must not itself fail; sections that cannot be
-/// gathered say so inline instead.
-public struct DiagnosticsReporter: Sendable {
- private let runner: any ProcessRunning
- private let mountTable: any MountTableReading
-
- public init(runner: any ProcessRunning, mountTable: any MountTableReading = LiveMountTable()) {
- self.runner = runner
- self.mountTable = mountTable
- }
-
- /// "lifsaver-report-20260717-1432.txt" — sortable, filesystem-safe.
- public static func suggestedFilename(for date: Date = Date()) -> String {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.dateFormat = "yyyyMMdd-HHmm"
- return "lifsaver-report-\(formatter.string(from: date)).txt"
- }
-
- /// `userNote` is the reporter's own description of what went wrong;
- /// `appEvents` are the menu bar app's recent scan/mount outcomes.
- public func generate(userNote: String = "", appEvents: [String] = []) async -> String {
- let note = userNote.trimmingCharacters(in: .whitespacesAndNewlines)
- var sections: [(String, String)] = [
- ("What happened", note.isEmpty ? "(not provided)" : note)
- ]
- if !appEvents.isEmpty {
- sections.append(("Recent app events", appEvents.joined(separator: "\n")))
- }
- sections.append(("Scan trace", await scanTrace()))
- sections.append(("Mount table", mountTableDump()))
- sections.append(("fsck processes", await fsckDump()))
- sections.append(("diskutil list -plist", await rawCommand("diskutil", ["list", "-plist"])))
- sections.append(("diskutil info -plist, per whole disk", await diskInfoDump()))
-
- return header() + "\n\n"
- + sections.map { "## \($0.0)\n\n\($0.1)" }.joined(separator: "\n\n") + "\n"
- }
-
- private func header() -> String {
- """
- # lifsaver diagnostic report
-
- - generated: \(ISO8601DateFormatter().string(from: Date()))
- - version: \(lifsaverVersion) (\(lifsaverInstallChannel()))
- - macOS: \(ProcessInfo.processInfo.operatingSystemVersionString)
- - architecture: \(buildArchitecture)
-
- This report contains your disk layout, volume names, and mount paths —
- no file contents. Review it before sharing.
- """
- }
-
- /// Re-runs the scanner with verbose diagnostics captured, then annotates
- /// each target with its filesystem type and fsck state.
- private func scanTrace() async -> String {
- let captured = OSAllocatedUnfairLock(initialState: [String]())
- let console = Console(
- out: { line in captured.withLock { $0.append(line) } },
- err: { line in captured.withLock { $0.append(line) } }
- )
- let scanner = DiskScanner(runner: runner, mountTable: mountTable, console: console, verbose: true)
-
- var lines: [String] = []
- do {
- let targets = try await scanner.scanTargets()
- if targets.isEmpty {
- lines.append("targets: (none)")
- }
- for devId in targets {
- let fsType = await scanner.partitionFSType(devId)
- let fsck = await scanner.isFsckActive(devId) ? "fsck running" : "fsck idle"
- lines.append("target: \(devId) (\(fsType.isEmpty ? "unknown fs" : fsType), \(fsck))")
- }
- } catch {
- lines.append("scan failed: \(error)")
- }
- return (captured.withLock { $0 } + lines).joined(separator: "\n")
- }
-
- private func mountTableDump() -> String {
- do {
- let entries = try mountTable.entries()
- guard !entries.isEmpty else { return "(empty)" }
- return entries.map { "\($0.device) → \($0.mountPoint)" }.joined(separator: "\n")
- } catch {
- return "unavailable: \(error)"
- }
- }
-
- private func fsckDump() async -> String {
- // pgrep exits 1 when nothing matches — not an error.
- let listing = await rawCommand("pgrep", ["-fl", "fsck"])
- let trimmed = listing.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.isEmpty ? "(none running)" : trimmed
- }
-
- /// The externality signals (`Internal`, `RemovableMediaOrExternalDevice`)
- /// exist only in per-disk `info` output, so dump it for every whole disk.
- private func diskInfoDump() async -> String {
- guard
- let result = try? await runner.run("diskutil", ["list", "-plist"], timeout: queryTimeout),
- let plist = try? PropertyListSerialization.propertyList(from: result.stdout, format: nil),
- let data = plist as? [String: Any]
- else { return "unavailable: could not enumerate disks" }
-
- let disks = (data["AllDisksAndPartitions"] as? [[String: Any]] ?? [])
- .compactMap { $0["DeviceIdentifier"] as? String }
- guard !disks.isEmpty else { return "(no disks)" }
-
- var chunks: [String] = []
- for disk in disks {
- chunks.append("--- \(disk) ---\n" + (await rawCommand("diskutil", ["info", "-plist", disk])))
- }
- return chunks.joined(separator: "\n")
- }
-
- private func rawCommand(_ executable: String, _ arguments: [String]) async -> String {
- do {
- let result = try await runner.run(executable, arguments, timeout: queryTimeout)
- return result.stdoutText.trimmingCharacters(in: .whitespacesAndNewlines)
- } catch {
- return "unavailable: \(error)"
- }
- }
-}
diff --git a/Sources/LifsaverCore/Report.swift b/Sources/LifsaverCore/Report.swift
deleted file mode 100644
index ff985dd..0000000
--- a/Sources/LifsaverCore/Report.swift
+++ /dev/null
@@ -1,49 +0,0 @@
-/// Wire format of the CLI's `--json` output, shared with the menu bar app so
-/// both ends agree on the schema by construction.
-///
-/// Encoded with sorted keys and nil fields omitted:
-///
-/// {"action":"scan","targets":["disk4s1"]}
-/// {"action":"mount","mounted":[…],"results":{"fail":0,"ok":1,"skip":0},"targets":["disk4s1"]}
-public struct CLIReport: Codable, Sendable, Equatable {
- public enum Action: String, Codable, Sendable {
- case scan
- case mount
- }
-
- public struct Counts: Codable, Sendable, Equatable {
- public var ok: Int
- public var fail: Int
- public var skip: Int
-
- public init(ok: Int = 0, fail: Int = 0, skip: Int = 0) {
- self.ok = ok
- self.fail = fail
- self.skip = skip
- }
- }
-
- public struct MountedVolume: Codable, Sendable, Equatable {
- public var device: String
- public var mountPoint: String
-
- public init(device: String, mountPoint: String) {
- self.device = device
- self.mountPoint = mountPoint
- }
- }
-
- public var action: Action
- public var targets: [String]
- /// Present for `action == .mount` only.
- public var results: Counts?
- /// Present for `action == .mount` only.
- public var mounted: [MountedVolume]?
-
- public init(action: Action, targets: [String], results: Counts? = nil, mounted: [MountedVolume]? = nil) {
- self.action = action
- self.targets = targets
- self.results = results
- self.mounted = mounted
- }
-}
diff --git a/Sources/LifsaverCore/UpgradeCommand.swift b/Sources/LifsaverCore/UpgradeCommand.swift
deleted file mode 100644
index a62f39f..0000000
--- a/Sources/LifsaverCore/UpgradeCommand.swift
+++ /dev/null
@@ -1,39 +0,0 @@
-import Foundation
-
-/// Best-effort guess at the upgrade command for this installation.
-///
-/// lifsaver ships through two channels that land identical files in
-/// /Applications, but only the Homebrew cask leaves a Caskroom metadata
-/// directory behind — its presence is what tells the channels apart.
-/// A .pkg install has no package manager to invoke, so the hint downloads
-/// the new installer directly; UpdateChecker fills in `{latest}` with the
-/// release version once it is known.
-/// True only when `path` is an existing *directory*: a plain file sitting at a
-/// Caskroom path must not be read as a cask install.
-public func directoryExistsOnDisk(_ path: String) -> Bool {
- var isDirectory: ObjCBool = false
- return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
- && isDirectory.boolValue
-}
-
-private let caskrooms = [
- "/opt/homebrew/Caskroom/lifsaver", // Apple silicon prefix
- "/usr/local/Caskroom/lifsaver", // Intel prefix
-]
-
-public func lifsaverUpgradeCommand(
- directoryExists: (String) -> Bool = directoryExistsOnDisk
-) -> String {
- if caskrooms.contains(where: directoryExists) {
- return "brew upgrade --cask lifsaver"
- }
- return "open https://github.com/lucuma13/lifsaver/releases/download/v{latest}/lifsaver_installer_macos.pkg"
-}
-
-/// Human-readable install channel for diagnostic reports, from the same
-/// Caskroom signal the upgrade hint uses.
-public func lifsaverInstallChannel(
- directoryExists: (String) -> Bool = directoryExistsOnDisk
-) -> String {
- caskrooms.contains(where: directoryExists) ? "Homebrew cask" : "installer package or local build"
-}
diff --git a/Sources/LifsaverKit/Console.swift b/Sources/LifsaverKit/Console.swift
new file mode 100644
index 0000000..ac0617a
--- /dev/null
+++ b/Sources/LifsaverKit/Console.swift
@@ -0,0 +1,105 @@
+import Foundation
+import os
+
+/// Injectable output sink so callers decide where diagnostic text lands: the
+/// app routes it to its log, diagnostic reports capture it, and the root
+/// helper sends it to stderr. `.standard` mirrors stdout/stderr for tests and
+/// bare `swift run` development.
+public struct Console: Sendable {
+ public var out: @Sendable (String) -> Void
+ public var err: @Sendable (String) -> Void
+
+ public init(out: @escaping @Sendable (String) -> Void, err: @escaping @Sendable (String) -> Void) {
+ self.out = out
+ self.err = err
+ }
+
+ public static let standard = Console(
+ out: { print($0) },
+ err: { writeStandardError($0 + "\n") }
+ )
+}
+
+/// Serializes writes to real stderr. The helper funnels both console streams
+/// here from tasks that can run concurrently, and raw `FileHandle` writes
+/// neither lock nor guarantee whole-line atomicity — so interleaved bytes are
+/// possible without this. Also uses the throwing overload rather than the
+/// deprecated one that raises an Objective-C exception on error.
+private let standardErrorQueue = DispatchQueue(label: "app.lifsaver.console.stderr")
+private func writeStandardError(_ text: String) {
+ standardErrorQueue.sync {
+ try? FileHandle.standardError.write(contentsOf: Data(text.utf8))
+ }
+}
+
+/// Timestamped, capped, thread-safe buffer of live `Console` output, so a
+/// diagnostic report can show what actually happened during past scans and
+/// mount attempts instead of only a re-scan taken at report time.
+///
+/// `console(alsoTo:)` builds the tee: every line is recorded here and forwarded
+/// to the wrapped console. Oldest lines roll off past `capacity` — the disk
+/// watcher rescans on every disk event, and an unbounded buffer would grow for
+/// as long as the app stays resident.
+public final class ConsoleLog: Sendable {
+ /// A recorded line keeps its `Date` rather than a formatted string: the
+ /// timestamp is rendered only in `snapshot()`, which almost nothing calls
+ /// (lines surface only when a diagnostic report is saved). A `nil` date
+ /// marks a line already formatted elsewhere — merged verbatim.
+ private struct Entry: Sendable {
+ let timestamp: Date?
+ let text: String
+ }
+
+ private let entries: OSAllocatedUnfairLock<[Entry]>
+ private let capacity: Int
+ private let now: @Sendable () -> Date
+
+ public init(capacity: Int = 400, now: @escaping @Sendable () -> Date = { Date() }) {
+ self.entries = OSAllocatedUnfairLock(initialState: [])
+ self.capacity = capacity
+ self.now = now
+ }
+
+ /// Records one line, stamped with the current time (formatted on read).
+ public func record(_ line: String) {
+ add([Entry(timestamp: now(), text: line)])
+ }
+
+ /// Appends already-formatted lines verbatim — for merging a log captured
+ /// elsewhere (the root helper timestamps its own lines).
+ public func append(_ newLines: [String]) {
+ add(newLines.map { Entry(timestamp: nil, text: $0) })
+ }
+
+ private func add(_ newEntries: [Entry]) {
+ entries.withLock {
+ $0.append(contentsOf: newEntries)
+ if $0.count > capacity {
+ $0.removeFirst($0.count - capacity)
+ }
+ }
+ }
+
+ public func snapshot() -> [String] {
+ entries.withLock { store in
+ store.map { entry in
+ guard let timestamp = entry.timestamp else { return entry.text }
+ return "\(timestamp.formatted(.iso8601)) \(entry.text)"
+ }
+ }
+ }
+
+ /// A console that records every line here and forwards it to `other`.
+ public func console(alsoTo other: Console) -> Console {
+ Console(
+ out: { line in
+ self.record(line)
+ other.out(line)
+ },
+ err: { line in
+ self.record(line)
+ other.err(line)
+ }
+ )
+ }
+}
diff --git a/Sources/LifsaverKit/DiagnosticsReport.swift b/Sources/LifsaverKit/DiagnosticsReport.swift
new file mode 100644
index 0000000..0bca3fd
--- /dev/null
+++ b/Sources/LifsaverKit/DiagnosticsReport.swift
@@ -0,0 +1,311 @@
+import Foundation
+import os
+
+#if arch(arm64)
+ private let buildArchitecture = "arm64"
+#elseif arch(x86_64)
+ private let buildArchitecture = "x86_64"
+#else
+ private let buildArchitecture = "unknown"
+#endif
+
+/// Where diagnostic reports should be emailed. Assembled at runtime from
+/// fragments so it never appears verbatim in the repository, on rendered
+/// GitHub pages, or in the shipped binary's string table.
+public let lifsaverSupportEmail = ["alterluigi", "+", "debug", "@", "gma", "il", ".", "com"].joined()
+
+/// A parsed JSON value, so third-party output that is already structured
+/// (`diskutil`'s plists) can be embedded as native, queryable JSON — keeping
+/// the whole report one uniform document.
+public enum JSONValue: Codable, Sendable {
+ case string(String)
+ case number(Double)
+ case bool(Bool)
+ case array([JSONValue])
+ case object([String: JSONValue])
+ case null
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ // Bool before Double: JSON `true` must not be read as a number.
+ if container.decodeNil() {
+ self = .null
+ } else if let bool = try? container.decode(Bool.self) {
+ self = .bool(bool)
+ } else if let number = try? container.decode(Double.self) {
+ self = .number(number)
+ } else if let string = try? container.decode(String.self) {
+ self = .string(string)
+ } else if let array = try? container.decode([JSONValue].self) {
+ self = .array(array)
+ } else if let object = try? container.decode([String: JSONValue].self) {
+ self = .object(object)
+ } else {
+ throw DecodingError.dataCorrupted(
+ .init(codingPath: decoder.codingPath, debugDescription: "unsupported JSON value"))
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .string(let value): try container.encode(value)
+ case .number(let value): try container.encode(value)
+ case .bool(let value): try container.encode(value)
+ case .array(let value): try container.encode(value)
+ case .object(let value): try container.encode(value)
+ case .null: try container.encodeNil()
+ }
+ }
+
+ public subscript(key: String) -> JSONValue? {
+ if case .object(let object) = self { return object[key] }
+ return nil
+ }
+ public var stringValue: String? { if case .string(let value) = self { return value } else { return nil } }
+ public var boolValue: Bool? { if case .bool(let value) = self { return value } else { return nil } }
+}
+
+/// Parses `diskutil`'s plist output into native JSON so the report stays one
+/// uniform structured document. Falls back to the raw text (itself valid JSON,
+/// as a string) when the output is not a parseable plist — an `unavailable:`
+/// marker or malformed output — which is exactly the case where seeing the raw
+/// bytes is what you want.
+func plistAsJSON(_ text: String) -> JSONValue {
+ guard
+ let object = try? PropertyListSerialization.propertyList(from: Data(text.utf8), format: nil),
+ JSONSerialization.isValidJSONObject(object),
+ let data = try? JSONSerialization.data(withJSONObject: object),
+ let value = try? JSONDecoder().decode(JSONValue.self, from: data)
+ else {
+ return .string(text)
+ }
+ return value
+}
+
+/// Diagnostic report, so a missed or unmountable card can be debugged. The
+/// app's own fields (metadata, detected targets, the mount table) and
+/// `diskutil`'s plist output alike are native JSON, so every value is directly
+/// queryable. The plists are parsed generically, so no keys are dropped.
+public struct DiagnosticReport: Codable, Sendable {
+ public struct Meta: Codable, Sendable {
+ public var generated: String
+ public var version: String
+ public var macOS: String
+ public var architecture: String
+ /// What the report contains, so a reader knows what they are sharing.
+ public var privacyNote: String
+ }
+
+ /// One detected target and the two facts the mounter branches on.
+ public struct Target: Codable, Sendable {
+ public var device: String
+ public var fsType: String
+ public var fsckActive: Bool
+ }
+
+ /// The verbose re-scan run at report time. `consoleOutput` is what the
+ /// scanner said as it decided; `error` is set instead of `targets` when the
+ /// scan itself threw. This is sampled *now*, not when the problem occurred —
+ /// `liveLog` holds what happened then.
+ public struct ScanTrace: Codable, Sendable {
+ public var consoleOutput: [String] = []
+ public var targets: [Target] = []
+ public var error: String?
+ }
+
+ public struct MountTableEntry: Codable, Sendable {
+ public var device: String
+ public var mountPoint: String
+ }
+
+ public struct DiskInfo: Codable, Sendable {
+ public var device: String
+ /// `diskutil info -plist` parsed to native JSON, or an `unavailable:`
+ /// string when it could not be read.
+ public var info: JSONValue
+ }
+
+ public var meta: Meta
+ /// The reporter's own description of what went wrong; nil when omitted.
+ public var userNote: String?
+ /// The app's recent scan/mount outcomes.
+ public var appEvents: [String]
+ /// Console output recorded live during those past scans and mount attempts.
+ public var liveLog: [String]
+ public var scanTrace: ScanTrace
+ /// The kernel mount table, or `mountTableError` when it could not be read.
+ public var mountTable: [MountTableEntry]
+ public var mountTableError: String?
+ /// `pgrep -fl fsck` lines; empty means none running.
+ public var fsckProcesses: [String]
+ /// `diskutil list -plist` parsed to native JSON, or an `unavailable:`
+ /// string when it could not be read.
+ public var diskutilList: JSONValue
+ public var diskInfo: [DiskInfo]
+
+ /// Pretty-printed, stable-key JSON — the form written to disk and emailed.
+ public func jsonString() -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ guard let data = try? encoder.encode(self) else {
+ return "{\"error\":\"could not encode diagnostic report\"}"
+ }
+ return String(decoding: data, as: UTF8.self)
+ }
+}
+
+/// Pre-addressed mailto: draft for sending a report. mailto cannot attach
+/// files, so the body asks the sender to attach the saved report themselves.
+public func lifsaverReportMailtoURL(reportFilename: String) -> URL? {
+ var components = URLComponents()
+ components.scheme = "mailto"
+ components.path = lifsaverSupportEmail
+ components.queryItems = [
+ URLQueryItem(name: "subject", value: "lifsaver diagnostic report"),
+ URLQueryItem(name: "body", value: "Please attach \(reportFilename) to this email before sending.\n\n"),
+ ]
+ return components.url
+}
+
+/// Assembles the plain-text diagnostic report.
+///
+/// Read-only: it re-runs the scanner verbosely and captures the raw
+/// `diskutil` plists the scanner decides from, so a missed or unmountable
+/// card can be replayed from the report alone. Generation never throws —
+/// a report about a failure must not itself fail; sections that cannot be
+/// gathered say so inline instead.
+public struct DiagnosticsReporter: Sendable {
+ private let runner: any ProcessRunning
+ private let mountTable: any MountTableReading
+
+ public init(runner: any ProcessRunning, mountTable: any MountTableReading = KernelMountTable()) {
+ self.runner = runner
+ self.mountTable = mountTable
+ }
+
+ /// "lifsaver-report-20260717-1432.json" — sortable, filesystem-safe.
+ public static func suggestedFilename(for date: Date = Date()) -> String {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyyMMdd-HHmm"
+ return "lifsaver-report-\(formatter.string(from: date)).json"
+ }
+
+ /// `userNote` is the reporter's own description of what went wrong;
+ /// `appEvents` are the app's recent scan/mount outcomes; `liveLog` is the
+ /// console output recorded live during those scans and mount attempts.
+ public func generate(
+ userNote: String = "", appEvents: [String] = [], liveLog: [String] = []
+ ) async -> DiagnosticReport {
+ let note = userNote.trimmingCharacters(in: .whitespacesAndNewlines)
+ let (mountEntries, mountError) = mountTableDump()
+ // One listing serves both the raw dump and the per-disk enumeration — a
+ // second spawn could disagree with the first mid-report, which is
+ // exactly the inconsistency a diagnostic report exists to rule out.
+ let listing = await rawCommand("diskutil", ["list", "-plist"])
+
+ return DiagnosticReport(
+ meta: meta(),
+ userNote: note.isEmpty ? nil : note,
+ appEvents: appEvents,
+ liveLog: liveLog,
+ scanTrace: await scanTrace(),
+ mountTable: mountEntries,
+ mountTableError: mountError,
+ fsckProcesses: await fsckDump(),
+ diskutilList: plistAsJSON(listing),
+ diskInfo: await diskInfoDump(fromListing: listing)
+ )
+ }
+
+ private func meta() -> DiagnosticReport.Meta {
+ DiagnosticReport.Meta(
+ generated: ISO8601DateFormatter().string(from: Date()),
+ version: lifsaverVersion,
+ macOS: ProcessInfo.processInfo.operatingSystemVersionString,
+ architecture: buildArchitecture,
+ privacyNote: "Contains disk layout, volume names, and mount paths — "
+ + "no file contents. Review it before sharing."
+ )
+ }
+
+ /// Re-runs the scanner with verbose diagnostics captured, then annotates
+ /// each target with its filesystem type and fsck state.
+ private func scanTrace() async -> DiagnosticReport.ScanTrace {
+ let captured = OSAllocatedUnfairLock(initialState: [String]())
+ let console = Console(
+ out: { line in captured.withLock { $0.append(line) } },
+ err: { line in captured.withLock { $0.append(line) } }
+ )
+ let scanner = DiskScanner(runner: runner, mountTable: mountTable, console: console, verbose: true)
+
+ var targets: [DiagnosticReport.Target] = []
+ var scanError: String?
+ do {
+ for devId in try await scanner.scanTargets() {
+ targets.append(
+ DiagnosticReport.Target(
+ device: devId,
+ fsType: await scanner.partitionFSType(devId),
+ fsckActive: await scanner.isFsckActive(devId)
+ ))
+ }
+ } catch {
+ scanError = "scan failed: \(error)"
+ }
+ return DiagnosticReport.ScanTrace(
+ consoleOutput: captured.withLock { $0 }, targets: targets, error: scanError)
+ }
+
+ /// Returns the mount table, or an empty list plus an `unavailable:` error
+ /// string when it cannot be read.
+ private func mountTableDump() -> ([DiagnosticReport.MountTableEntry], String?) {
+ do {
+ let entries = try mountTable.entries()
+ return (entries.map { .init(device: $0.device, mountPoint: $0.mountPoint) }, nil)
+ } catch {
+ return ([], "unavailable: \(error)")
+ }
+ }
+
+ private func fsckDump() async -> [String] {
+ // pgrep exits 1 when nothing matches — not an error.
+ let listing = await rawCommand("pgrep", ["-fl", "fsck"])
+ return listing.split(separator: "\n").map(String.init)
+ }
+
+ /// The externality signals (`Internal`, `RemovableMediaOrExternalDevice`)
+ /// exist only in per-disk `info` output, so dump it for every whole disk
+ /// named in the already-captured listing.
+ private func diskInfoDump(fromListing listing: String) async -> [DiagnosticReport.DiskInfo] {
+ guard
+ let plist = try? PropertyListSerialization.propertyList(from: Data(listing.utf8), format: nil),
+ let data = plist as? [String: Any]
+ else {
+ return [
+ DiagnosticReport.DiskInfo(
+ device: "", info: .string("unavailable: could not enumerate disks"))
+ ]
+ }
+
+ let disks = (data["AllDisksAndPartitions"] as? [[String: Any]] ?? [])
+ .compactMap { $0["DeviceIdentifier"] as? String }
+
+ var infos: [DiagnosticReport.DiskInfo] = []
+ for disk in disks {
+ infos.append(
+ .init(device: disk, info: plistAsJSON(await rawCommand("diskutil", ["info", "-plist", disk]))))
+ }
+ return infos
+ }
+
+ private func rawCommand(_ executable: String, _ arguments: [String]) async -> String {
+ do {
+ let result = try await runner.run(executable, arguments, timeout: queryTimeout)
+ return result.stdoutText.trimmingCharacters(in: .whitespacesAndNewlines)
+ } catch {
+ return "unavailable: \(error)"
+ }
+ }
+}
diff --git a/Sources/LifsaverKit/EscalatedMountHelper.swift b/Sources/LifsaverKit/EscalatedMountHelper.swift
new file mode 100644
index 0000000..e48b977
--- /dev/null
+++ b/Sources/LifsaverKit/EscalatedMountHelper.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+/// Root side of the app's escalated mount: the menu bar app re-runs its own
+/// binary under the administrator password dialog with `helperFlag`, and this
+/// scans and mounts everything found, printing a `MountReport` JSON document
+/// on stdout for the app to parse.
+public enum EscalatedMountHelper {
+ /// Hidden argv flag marking an invocation as the escalated helper rather
+ /// than the menu bar app. Frozen: an updated app may invoke a not yet
+ /// restarted older binary's flag and vice versa.
+ public static let helperFlag = "--escalated-mount"
+
+ /// Scan, mount every target, and emit the report. Stdout carries only the
+ /// JSON document; diagnostics go to the console (stderr by default, which
+ /// the invoking app discards) and are also carried home in the report's
+ /// `log`.
+ public static func run(
+ runner: any ProcessRunning = DefaultProcessRunner(),
+ mountTable: any MountTableReading = KernelMountTable(),
+ fileOps: any FileOperating = DefaultFileOperations(),
+ console: Console = Console(out: Console.standard.err, err: Console.standard.err),
+ emit: (String) -> Void = { print($0) }
+ ) async -> Int32 {
+ let log = ConsoleLog()
+ let console = log.console(alsoTo: console)
+ // Verbose: skip reasons only ever land in the report log, and "why was
+ // my card skipped as root" is exactly what a mount bug report turns on.
+ let scanner = DiskScanner(runner: runner, mountTable: mountTable, console: console, verbose: true)
+
+ let targets: [String]
+ do {
+ targets = try await scanner.scanTargets()
+ } catch {
+ // The invoking app discards this process's stderr, so the failure
+ // must also travel in the JSON report — otherwise the user pays for
+ // a password dialog and learns nothing about why it failed.
+ console.err("CRITICAL: \(error)")
+ emitReport(
+ MountReport(targets: [], error: "root-side scan failed: \(error)", log: log.snapshot()),
+ emit: emit)
+ return 1
+ }
+
+ let pass = await Mounter(scanner: scanner, fileOps: fileOps).mountAll(targets)
+ emitReport(
+ MountReport(targets: targets, results: pass.counts, mounted: pass.mounted, log: log.snapshot()),
+ emit: emit)
+ return pass.counts.fail > 0 ? 1 : 0
+ }
+
+ private static func emitReport(_ report: MountReport, emit: (String) -> Void) {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ if let data = try? encoder.encode(report) {
+ emit(String(decoding: data, as: UTF8.self))
+ }
+ }
+}
diff --git a/Sources/LifsaverCore/MountTable.swift b/Sources/LifsaverKit/KernelMountTable.swift
similarity index 97%
rename from Sources/LifsaverCore/MountTable.swift
rename to Sources/LifsaverKit/KernelMountTable.swift
index c632b20..aa55e61 100644
--- a/Sources/LifsaverCore/MountTable.swift
+++ b/Sources/LifsaverKit/KernelMountTable.swift
@@ -23,7 +23,7 @@ public protocol MountTableReading: Sendable {
/// Live kernel mount table via getmntinfo_r_np(3) — the same data `mount`
/// prints, without a subprocess or text parsing. The _r variant is
/// thread-safe; the plain getmntinfo shares one static buffer per process.
-public struct LiveMountTable: MountTableReading {
+public struct KernelMountTable: MountTableReading {
public init() {}
public func entries() throws -> [MountEntry] {
diff --git a/Sources/LifsaverKit/MountReport.swift b/Sources/LifsaverKit/MountReport.swift
new file mode 100644
index 0000000..fdfbe50
--- /dev/null
+++ b/Sources/LifsaverKit/MountReport.swift
@@ -0,0 +1,52 @@
+/// Wire format the escalated helper prints on stdout, shared with the menu
+/// bar app so both ends agree on the schema by construction.
+///
+/// Encoded with sorted keys:
+///
+/// {"log":[…],"mounted":[…],"results":{"fail":0,"ok":1,"skip":0},"targets":["disk4s1"]}
+public struct MountReport: Codable, Sendable, Equatable {
+ public struct Counts: Codable, Sendable, Equatable {
+ public var ok: Int
+ public var fail: Int
+ public var skip: Int
+
+ public init(ok: Int = 0, fail: Int = 0, skip: Int = 0) {
+ self.ok = ok
+ self.fail = fail
+ self.skip = skip
+ }
+ }
+
+ public struct MountedVolume: Codable, Sendable, Equatable {
+ public var device: String
+ public var mountPoint: String
+
+ public init(device: String, mountPoint: String) {
+ self.device = device
+ self.mountPoint = mountPoint
+ }
+ }
+
+ public var targets: [String]
+ public var results: Counts
+ public var mounted: [MountedVolume]
+ /// Set when the helper failed before mounting anything (e.g. its scan
+ /// threw). Carried in-band because the invoking app discards the helper's
+ /// stderr — this is the only channel that survives the escalation.
+ public var error: String?
+ /// Timestamped console lines the helper recorded while scanning and
+ /// mounting, carried in-band for the same reason as `error`. The app merges
+ /// them into its live log for diagnostic reports.
+ public var log: [String]
+
+ public init(
+ targets: [String], results: Counts = .init(), mounted: [MountedVolume] = [],
+ error: String? = nil, log: [String] = []
+ ) {
+ self.targets = targets
+ self.results = results
+ self.mounted = mounted
+ self.error = error
+ self.log = log
+ }
+}
diff --git a/Sources/LifsaverCore/Mounter.swift b/Sources/LifsaverKit/Mounter.swift
similarity index 76%
rename from Sources/LifsaverCore/Mounter.swift
rename to Sources/LifsaverKit/Mounter.swift
index f2738ec..e9ba97f 100644
--- a/Sources/LifsaverCore/Mounter.swift
+++ b/Sources/LifsaverKit/Mounter.swift
@@ -70,8 +70,10 @@ public struct Mounter: Sendable {
console.out("\nTarget: /dev/\(devId)")
}
- // Re-query live mount table immediately before acting (race guard)
- if scanner.isCurrentlyMounted(devId) {
+ // Re-query live mount table immediately before acting (race guard). nil
+ // (table unreadable) proceeds: diskutil mount on an already mounted
+ // volume is a harmless no-op, skipping a stalled one is not.
+ if scanner.isCurrentlyMounted(devId) == true {
console.out(" SKIPPED — /dev/\(devId) became mounted since scan.")
return .skip
}
@@ -79,7 +81,7 @@ public struct Mounter: Sendable {
// Never fight a repair in progress — wait for macOS to finish or bail out.
if await scanner.isFsckActive(devId) {
console.out(" SKIPPED — macOS is running a consistency check (fsck) on /dev/\(devId).")
- console.out(" Let it finish and re-run; mounting mid-check risks corrupting the card.")
+ console.out(" Let it finish and mount again from the menu; mounting mid-check risks corrupting the card.")
return .skip
}
@@ -102,12 +104,15 @@ public struct Mounter: Sendable {
/// Try `diskutil mount` first (preferred; handles LIFS sandboxing), then
/// fall back to raw mount binaries. Verifies against the live mount table
- /// after each attempt.
+ /// after each attempt; when the table cannot be read (nil), the mount
+ /// binary's zero exit stands — an unreadable table must not turn a
+ /// successful mount into a spurious failure (and, one level up, into an
+ /// unwarranted admin password prompt).
func attemptMounts(_ devId: String, fsType: String) async -> Bool {
if verbose {
console.out(" Attempting diskutil mount...")
}
- if await diskutilMount(devId), scanner.isCurrentlyMounted(devId) {
+ if await diskutilMount(devId), scanner.isCurrentlyMounted(devId) != false {
if verbose {
let location = scanner.mountPoint(of: devId)
console.out(" SUCCESS via diskutil → \(location.isEmpty ? "(see /Volumes)" : location)")
@@ -121,7 +126,7 @@ public struct Mounter: Sendable {
console.out(" diskutil mount failed; falling back to raw mount binaries...")
}
if await rawMount(devId, fsType: fsType) {
- if scanner.isCurrentlyMounted(devId) {
+ if scanner.isCurrentlyMounted(devId) != false {
if verbose {
console.out(" SUCCESS via raw mount → \(rawMountPoint(devId))")
}
@@ -135,6 +140,33 @@ public struct Mounter: Sendable {
return false
}
+ /// One pass over `targets`: mount each and tally, collecting the mount
+ /// points of the volumes this pass mounted. Shared by the unprivileged
+ /// first pass and the escalated root helper so their tallies can never
+ /// drift.
+ public struct PassResult: Sendable {
+ public var counts = MountReport.Counts()
+ public var mounted: [MountReport.MountedVolume] = []
+
+ public init() {}
+ }
+
+ public func mountAll(_ targets: [String]) async -> PassResult {
+ var result = PassResult()
+ for devId in targets {
+ switch await execute(devId) {
+ case .ok:
+ result.counts.ok += 1
+ result.mounted.append(.init(device: devId, mountPoint: scanner.mountPoint(of: devId)))
+ case .fail:
+ result.counts.fail += 1
+ case .skip:
+ result.counts.skip += 1
+ }
+ }
+ return result
+ }
+
/// Where rawMount grafts the volume — the raw binaries need an explicit,
/// pre-created mount point (diskutil manages its own under /Volumes).
func rawMountPoint(_ devId: String) -> String {
@@ -149,17 +181,25 @@ public struct Mounter: Sendable {
do {
result = try await runner.run("diskutil", ["mount", devId], timeout: mountTimeout)
} catch {
- if verbose {
- console.err(" [diskutil error] \(error)")
- }
+ logError("diskutil", error)
return false
}
- if verbose && !result.stderr.isEmpty {
- console.err(" [diskutil stderr] \(result.stderr.trimmingCharacters(in: .whitespacesAndNewlines))")
- }
+ logStderr("diskutil", result)
return result.status == 0
}
+ /// Verbose diagnostic logging shared by every mount attempt, so the
+ /// diskutil and raw-binary paths always trace identically.
+ private func logStderr(_ label: String, _ result: ProcessResult) {
+ guard verbose, !result.stderr.isEmpty else { return }
+ console.err(" [\(label) stderr] \(result.stderr.trimmingCharacters(in: .whitespacesAndNewlines))")
+ }
+
+ private func logError(_ label: String, _ error: any Error) {
+ guard verbose else { return }
+ console.err(" [\(label) error] \(error)")
+ }
+
/// Fallback: use low-level mount binaries when diskutil mount is
/// unavailable or returns an error. Mount-point directory is created and
/// cleaned up on failure.
@@ -201,14 +241,10 @@ public struct Mounter: Sendable {
} catch {
// Missing binary (removed in newer macOS) or a hung card reader:
// move on to the next candidate rather than crashing.
- if verbose {
- console.err(" [\(name) error] \(error)")
- }
+ logError(name, error)
continue
}
- if verbose && !result.stderr.isEmpty {
- console.err(" [\(name) stderr] \(result.stderr.trimmingCharacters(in: .whitespacesAndNewlines))")
- }
+ logStderr(name, result)
if result.status == 0 {
return true
}
diff --git a/Sources/LifsaverCore/ProcessRunner.swift b/Sources/LifsaverKit/ProcessRunner.swift
similarity index 80%
rename from Sources/LifsaverCore/ProcessRunner.swift
rename to Sources/LifsaverKit/ProcessRunner.swift
index 863a704..2f2ce78 100644
--- a/Sources/LifsaverCore/ProcessRunner.swift
+++ b/Sources/LifsaverKit/ProcessRunner.swift
@@ -1,4 +1,5 @@
import Foundation
+import os
public struct ProcessResult: Sendable {
public let status: Int32
@@ -77,18 +78,7 @@ public struct DefaultProcessRunner: ProcessRunning {
process.standardError = errPipe
process.standardInput = FileHandle.nullDevice
- // Installed before run() so an instantly-exiting child cannot slip
- // past; the stream buffers the exit until it is awaited. The reason
- // rides along so a timeout can be told apart from a natural exit.
- let (terminations, termination) = AsyncStream.makeStream(of: ChildExit.self)
- process.terminationHandler = { process in
- termination.yield(
- ChildExit(
- status: process.terminationStatus,
- killedBySignal: process.terminationReason == .uncaughtSignal
- ))
- termination.finish()
- }
+ let (terminations, exited) = Self.terminationStream(for: process)
do {
try process.run()
@@ -103,7 +93,7 @@ public struct DefaultProcessRunner: ProcessRunning {
async let stderrData = Self.drain(errPipe.fileHandleForReading)
let pid = process.processIdentifier
- let watchdog = Self.watchdog(pid: pid, timeout: timeout)
+ let watchdog = Self.watchdog(pid: pid, timeout: timeout, exited: exited)
var childExit: ChildExit?
for await exit in terminations {
@@ -114,7 +104,9 @@ public struct DefaultProcessRunner: ProcessRunning {
guard let childExit else {
// Only possible when the surrounding task was cancelled, which
// ends stream iteration early: reap the child and propagate.
- kill(pid, SIGKILL)
+ if !exited.withLock({ $0 }) {
+ kill(pid, SIGKILL)
+ }
process.waitUntilExit()
_ = await (stdoutData, stderrData)
throw CancellationError()
@@ -138,15 +130,45 @@ public struct DefaultProcessRunner: ProcessRunning {
)
}
+ /// Installed before run() so an instantly-exiting child cannot slip past;
+ /// the stream buffers the exit until it is awaited. The reason rides along
+ /// so a timeout can be told apart from a natural exit. `exited` flips
+ /// synchronously in the handler so a late signal sender can tell "still
+ /// running" from "already reaped": a SIGKILL aimed at a reaped pid can hit
+ /// an unrelated, recycled pid — as root in the escalated helper. The check
+ /// narrows that window to the instant between Foundation reaping the child
+ /// and the handler running.
+ private static func terminationStream(
+ for process: Process
+ ) -> (AsyncStream, OSAllocatedUnfairLock) {
+ let exited = OSAllocatedUnfairLock(initialState: false)
+ let (terminations, termination) = AsyncStream.makeStream(of: ChildExit.self)
+ process.terminationHandler = { process in
+ exited.withLock { $0 = true }
+ termination.yield(
+ ChildExit(
+ status: process.terminationStatus,
+ killedBySignal: process.terminationReason == .uncaughtSignal
+ ))
+ termination.finish()
+ }
+ return (terminations, exited)
+ }
+
/// SIGKILLs `pid` once `timeout` elapses; the task's value reports
/// whether it fired. An infinite timeout means no watchdog at all — for
/// commands that legitimately wait on the user (e.g. a password dialog).
- private static func watchdog(pid: pid_t, timeout: TimeInterval) -> Task? {
+ private static func watchdog(
+ pid: pid_t, timeout: TimeInterval, exited: OSAllocatedUnfairLock
+ ) -> Task? {
guard timeout.isFinite else { return nil }
return Task {
guard (try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))) != nil else {
return false // cancelled — the child exited in time
}
+ // A child that exited right at the timeout boundary was reaped; its
+ // pid may already belong to someone else. Never kill it.
+ guard !exited.withLock({ $0 }) else { return false }
kill(pid, SIGKILL)
return true
}
diff --git a/Sources/LifsaverCore/Scanner.swift b/Sources/LifsaverKit/Scanner.swift
similarity index 61%
rename from Sources/LifsaverCore/Scanner.swift
rename to Sources/LifsaverKit/Scanner.swift
index 3779a46..863a62a 100644
--- a/Sources/LifsaverCore/Scanner.swift
+++ b/Sources/LifsaverKit/Scanner.swift
@@ -5,8 +5,10 @@ import Foundation
// ---------------------------------------------------------------------------
/// Partition "Content" values reported by `diskutil list -plist`. These name
-/// MBR/GPT partition types, not filesystems:
-public let externalFSAllowlist: [String] = [
+/// MBR/GPT partition types, not filesystems. Membership is exact: the values
+/// diskutil emits are enumerable, and exact matching can never widen to an
+/// Apple/EFI system partition when a new value is added.
+public let externalFSAllowlist: Set = [
"DOS_FAT_32", // MBR type 0x0B — FAT32 as formatted by macOS Disk Utility
"Windows_FAT_32", // MBR type 0x0C (FAT32 LBA) — FAT32 as formatted by Windows / SD Formatter
"Windows_NTFS", // MBR type 0x07 — shared by exFAT and NTFS; SDXC cards formatted in-camera report this
@@ -15,10 +17,10 @@ public let externalFSAllowlist: [String] = [
"ExFAT", // values (exFAT reports Windows_NTFS or Microsoft Basic Data)
]
-// Blocklist: EFI, recovery, and Apple container types.
-// Redundant with the allowlist today, but kept as a second interlock:
-// the allowlist uses substring matching and gets edited — a future broad
-// token must still never expose Apple/EFI system partitions.
+// Blocklist: EFI, recovery, and Apple container types. Redundant with the
+// allowlist today, but kept as a second interlock: the allowlist gets edited,
+// and no future entry may ever expose Apple/EFI system partitions. Deliberately
+// substring-based — a blocklist erring broad is safe.
let blockedContentTokens = [
"EFI",
"Apple_APFS",
@@ -60,8 +62,8 @@ public struct DiskScanner: Sendable {
public init(
runner: any ProcessRunning,
- mountTable: any MountTableReading = LiveMountTable(),
- console: Console = .standard,
+ mountTable: any MountTableReading = KernelMountTable(),
+ console: Console,
verbose: Bool = false
) {
self.runner = runner
@@ -74,24 +76,27 @@ public struct DiskScanner: Sendable {
/// The set of currently-mounted device paths (e.g. {"/dev/disk4s1", ...}),
/// from a fresh kernel snapshot. Virtual filesystems (devfs, autofs maps)
- /// are excluded.
- public func activeMounts() -> Set {
+ /// are excluded. Throws when the table cannot be read: an empty set means
+ /// "nothing mounted", and callers must never mistake a read failure for
+ /// that — it would turn every mounted volume into a mount target.
+ public func activeMounts() throws -> Set {
+ Set(try mountTable.entries().map(\.device).filter { $0.hasPrefix("/dev/") })
+ }
+
+ /// Re-query the live mount table for a single device. Always takes a fresh
+ /// snapshot — never relies on a cached set. nil when the table cannot be
+ /// read ("unknown", not "unmounted").
+ public func isCurrentlyMounted(_ devId: String) -> Bool? {
do {
- return Set(try mountTable.entries().map(\.device).filter { $0.hasPrefix("/dev/") })
+ return try activeMounts().contains("/dev/\(devId)")
} catch {
console.err("WARNING: Could not read mount table: \(error)")
- return []
+ return nil
}
}
- /// Re-query the live mount table for a single device.
- /// Always takes a fresh snapshot — never relies on a cached set.
- public func isCurrentlyMounted(_ devId: String) -> Bool {
- activeMounts().contains("/dev/\(devId)")
- }
-
- /// The current mount point for a device, or "" when it is not mounted
- /// (or the table cannot be read).
+ /// The current mount point for a device, or "" when it is not mounted (or
+ /// the table cannot be read).
public func mountPoint(of devId: String) -> String {
let devPath = "/dev/\(devId)"
let entries = (try? mountTable.entries()) ?? []
@@ -108,16 +113,22 @@ public struct DiskScanner: Sendable {
/// Matches both /dev/diskXsY and the raw /dev/rdiskXsY node fsck actually
/// opens, without matching longer identifiers (disk4s1 ≠ disk4s10).
public func isFsckActive(_ devId: String) async -> Bool {
- let result: ProcessResult
- do {
- // pgrep exits 1 when nothing matches — not an error
- result = try await runner.run("pgrep", ["-fl", "fsck"], timeout: queryTimeout)
- } catch {
- return false
- }
+ isFsckActive(devId, listing: await fsckListing())
+ }
+
+ /// One `pgrep -fl fsck` snapshot, matchable against many devices via
+ /// `isFsckActive(_:listing:)` — a scan pass needs one snapshot, not one
+ /// subprocess per device. Empty when nothing matches or pgrep fails.
+ public func fsckListing() async -> String {
+ // pgrep exits 1 when nothing matches — not an error
+ ((try? await runner.run("pgrep", ["-fl", "fsck"], timeout: queryTimeout))?.stdoutText) ?? ""
+ }
+
+ /// Pure match of one device against a captured fsck listing.
+ public func isFsckActive(_ devId: String, listing: String) -> Bool {
let pattern = "\\br?" + NSRegularExpression.escapedPattern(for: devId) + "\\b"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
- return result.stdoutText.split(separator: "\n").contains { line in
+ return listing.split(separator: "\n").contains { line in
let string = String(line)
let range = NSRange(string.startIndex..., in: string)
return regex.firstMatch(in: string, range: range) != nil
@@ -143,23 +154,39 @@ public struct DiskScanner: Sendable {
return data
}
+ /// One `diskutil info -plist` fetch-and-parse, shared by every per-device
+ /// query so timeout and error semantics can only ever change in one place.
+ private func diskInfo(_ devId: String) async -> [String: Any]? {
+ guard
+ let result = try? await runner.runChecked("diskutil", ["info", "-plist", devId], timeout: queryTimeout),
+ let plist = try? PropertyListSerialization.propertyList(from: result.stdout, format: nil),
+ let info = plist as? [String: Any]
+ else { return nil }
+ return info
+ }
+
+ /// The `Content` fallback names MBR partition types, not filesystems — map
+ /// the unambiguous FAT ones onto the token the mount path understands, so a
+ /// card without a `FilesystemType` key still tries mount_msdos first.
+ /// (exFAT/NTFS share their partition types, so those stay untranslated.)
+ private static let contentFSTypeAliases = [
+ "dos_fat_32": "msdos",
+ "windows_fat_32": "msdos",
+ ]
+
/// Ask diskutil for the actual filesystem type of a partition so we can
/// choose the right mount binary without trial-and-error.
///
/// Returns a lowercase string such as "exfat", "msdos", "hfs", or "" if the
/// information is unavailable.
public func partitionFSType(_ devId: String) async -> String {
- guard
- let result = try? await runner.runChecked("diskutil", ["info", "-plist", devId], timeout: queryTimeout),
- let plist = try? PropertyListSerialization.propertyList(from: result.stdout, format: nil),
- let info = plist as? [String: Any]
- else { return "" }
+ guard let info = await diskInfo(devId) else { return "" }
// "FilesystemType" is the canonical key; fall back to content hint.
// An empty string counts as absent — the fallback must still apply.
let fsType = info["FilesystemType"] as? String
let content = info["Content"] as? String
- let fs = [fsType, content].compactMap { $0 }.first { !$0.isEmpty } ?? ""
- return fs.lowercased()
+ let fs = ([fsType, content].compactMap { $0 }.first { !$0.isEmpty } ?? "").lowercased()
+ return Self.contentFSTypeAliases[fs] ?? fs
}
// --- partition filtering ---------------------------------------------------
@@ -173,16 +200,20 @@ public struct DiskScanner: Sendable {
///
/// - `Internal == false` — device sits on an external bus
/// - `RemovableMediaOrExternalDevice == true` — second signal covering
- /// card readers whose USB bridges misreport `Internal`, and built-in
- /// SD slots (removable media on an internal bus)
+ /// card readers whose USB bridges misreport `Internal`, and built-in SD
+ /// slots (removable media on an internal bus)
///
- /// Fails closed: an unreadable plist or missing keys count as internal.
+ /// Fails closed: an unreadable plist or missing keys count as internal. A
+ /// failed *query* gets one retry first — diskutil is most likely to be
+ /// flaky exactly when diskarbitrationd is wedged on a stalled card, and a
+ /// single transient failure must not hide the card the app exists to
+ /// rescue. A successful query whose keys say "internal" is final.
public func isExternalHardware(_ diskID: String) async -> Bool {
- guard
- let result = try? await runner.runChecked("diskutil", ["info", "-plist", diskID], timeout: queryTimeout),
- let plist = try? PropertyListSerialization.propertyList(from: result.stdout, format: nil),
- let info = plist as? [String: Any]
- else { return false }
+ var info = await diskInfo(diskID)
+ if info == nil {
+ info = await diskInfo(diskID)
+ }
+ guard let info else { return false }
return info["Internal"] as? Bool == false
|| info["RemovableMediaOrExternalDevice"] as? Bool == true
}
@@ -197,29 +228,47 @@ public struct DiskScanner: Sendable {
/// EFI system partitions and Apple_APFS / Apple_HFS containers are
/// explicitly excluded.
public func filterTargetPartitions(_ diskData: [String: Any], activeMounts: Set) async -> [String] {
- var targets: [String] = []
-
let disks = diskData["AllDisksAndPartitions"] as? [[String: Any]] ?? []
+
+ // Content checks first, so `diskutil info` only runs for disks that
+ // actually carry camera-card-like partitions. Unpartitioned
+ // (superfloppy) media put the filesystem on the whole-disk node itself,
+ // so a disk without partitions is its own candidate.
+ var perDisk: [(diskID: String, candidates: [String])] = []
for disk in disks {
let partitions = disk["Partitions"] as? [[String: Any]] ?? []
-
- // Content checks first, so `diskutil info` only runs for disks
- // that actually carry camera-card-like partitions.
- let candidates = partitions.filter(isCandidatePartition)
+ let nodes = partitions.isEmpty ? [disk] : partitions
+ let candidates = nodes.filter(isCandidatePartition)
+ .compactMap { $0["DeviceIdentifier"] as? String }
guard !candidates.isEmpty else { continue }
+ perDisk.append((disk["DeviceIdentifier"] as? String ?? "", candidates))
+ }
- // Strict boundary: internal disks are never touched.
- let diskID = disk["DeviceIdentifier"] as? String ?? ""
- guard !diskID.isEmpty, await isExternalHardware(diskID) else {
+ // Strict boundary: internal disks are never touched. The per-disk
+ // queries are independent, so they run concurrently — a wedged diskutil
+ // stalls the scan once, not once per disk.
+ let externality = await withTaskGroup(of: (Int, Bool).self) { group in
+ for (index, entry) in perDisk.enumerated() {
+ let diskID = entry.diskID
+ group.addTask { (index, diskID.isEmpty ? false : await isExternalHardware(diskID)) }
+ }
+ var results = [Bool](repeating: false, count: perDisk.count)
+ for await (index, isExternal) in group {
+ results[index] = isExternal
+ }
+ return results
+ }
+
+ var targets: [String] = []
+ for (index, entry) in perDisk.enumerated() {
+ guard externality[index] else {
if verbose {
- console.out(" Skipping \(diskID.isEmpty ? "unidentified disk" : diskID) — not external hardware.")
+ let name = entry.diskID.isEmpty ? "unidentified disk" : entry.diskID
+ console.out(" Skipping \(name) — not external hardware.")
}
continue
}
-
- for partition in candidates {
- let devId = partition["DeviceIdentifier"] as? String ?? ""
-
+ for devId in entry.candidates {
// Safety gate: skip anything already in the mount table
if activeMounts.contains("/dev/\(devId)") {
if verbose {
@@ -227,7 +276,6 @@ public struct DiskScanner: Sendable {
}
continue
}
-
targets.append(devId)
}
}
@@ -236,8 +284,7 @@ public struct DiskScanner: Sendable {
}
/// Content gate for a single partition, with a logged reason for every
- /// rejection so `--verbose` runs and diagnostic reports can replay the
- /// decision.
+ /// rejection so diagnostic reports can replay the decision.
private func isCandidatePartition(_ partition: [String: Any]) -> Bool {
let contentType = partition["Content"] as? String ?? ""
let devId = partition["DeviceIdentifier"] as? String ?? ""
@@ -252,7 +299,7 @@ public struct DiskScanner: Sendable {
}
// Allowlist: recognised camera-card payload types
- guard externalFSAllowlist.contains(where: { contentType.contains($0) }) else {
+ guard externalFSAllowlist.contains(contentType) else {
if verbose {
let shown = contentType.isEmpty ? "(empty)" : contentType
console.out(" Skipping \(devId) — Content \(shown) is not camera-card-like.")
@@ -265,6 +312,14 @@ public struct DiskScanner: Sendable {
/// One-call read-only scan: disk data + a fresh mount-table snapshot → targets.
public func scanTargets() async throws -> [String] {
let data = try await diskData()
- return await filterTargetPartitions(data, activeMounts: activeMounts())
+ let mounts: Set
+ do {
+ mounts = try activeMounts()
+ } catch {
+ // A truthful "scan failed" beats treating every mounted volume as
+ // a stalled target.
+ throw DiskUtilError(message: "Failed to read mount table: \(error)")
+ }
+ return await filterTargetPartitions(data, activeMounts: mounts)
}
}
diff --git a/Sources/LifsaverCore/StatusMenuModel.swift b/Sources/LifsaverKit/StatusMenuModel.swift
similarity index 74%
rename from Sources/LifsaverCore/StatusMenuModel.swift
rename to Sources/LifsaverKit/StatusMenuModel.swift
index e30a6f9..ef5bc77 100644
--- a/Sources/LifsaverCore/StatusMenuModel.swift
+++ b/Sources/LifsaverKit/StatusMenuModel.swift
@@ -1,30 +1,11 @@
-/// Result of the menu bar app's escalated CLI mount, decoupled from the
+/// Result of the menu bar app's escalated mount pass, decoupled from the
/// escalation mechanism so presentation logic on top of it is testable here.
public enum EscalatedMountOutcome: Equatable, Sendable {
- case report(CLIReport.Counts)
+ case report(MountReport.Counts)
case cancelled
- case cliNotFound
case error(String)
}
-/// Invalidates in-flight scan results once a newer scan has started.
-public struct ScanGeneration: Sendable {
- private var current = 0
-
- public init() {}
-
- /// Marks the start of a new scan and returns its token.
- public mutating func begin() -> Int {
- current += 1
- return current
- }
-
- /// True while `token` still belongs to the most recently started scan.
- public func isCurrent(_ token: Int) -> Bool {
- token == current
- }
-}
-
/// Tracks which stalled volumes the user has already been alerted about, so
/// the background disk watcher notifies once per card, not once per rescan.
public struct StalledWatchState: Sendable {
@@ -77,7 +58,9 @@ public enum StatusMenuModel {
case separator
case checkForUpdates(title: String)
case updateAvailable(title: String)
- case launchAtLogin(enabled: Bool)
+ /// The "Settings" submenu. `showStartAtLogin` is false on unbundled
+ /// dev runs, where SMAppService has no bundle to register.
+ case moreOptions(showStartAtLogin: Bool, startAtLoginEnabled: Bool, automaticUpdatesEnabled: Bool)
case saveReport(title: String)
case quit(title: String)
}
@@ -85,9 +68,9 @@ public enum StatusMenuModel {
public static func entries(
state: ScanState,
newerVersion: String?,
- isCheckingForUpdates: Bool = false,
showLaunchAtLogin: Bool,
- launchAtLoginEnabled: Bool
+ launchAtLoginEnabled: Bool,
+ automaticUpdatesEnabled: Bool
) -> [Entry] {
var entries: [Entry] = []
@@ -95,7 +78,7 @@ public enum StatusMenuModel {
case .scanning:
entries.append(.disabled("Scanning…"))
case .failed:
- entries.append(.disabled("Scan failed — try `lifsaver --verbose` in Terminal"))
+ entries.append(.disabled("Scan failed"))
case .results(let targets) where targets.isEmpty:
entries.append(.disabled("No stalled volumes detected"))
case .results(let targets):
@@ -105,21 +88,22 @@ public enum StatusMenuModel {
entries.append(.separator)
- if showLaunchAtLogin {
- entries.append(.launchAtLogin(enabled: launchAtLoginEnabled))
- }
// Always present — reports are most needed exactly when scans fail.
entries.append(.saveReport(title: "Send Diagnostic Report"))
- // The update item is always present, kept second-to-last: it opens the
- // latest installer once a newer version is known, otherwise it triggers
- // a manual check on click.
+ // The update item is always present: it opens the latest installer once
+ // a newer version is known, otherwise it triggers a manual check on
+ // click. The check's own progress and result are shown in an alert, not
+ // the menu, which closes the instant the item is clicked.
if let newerVersion {
entries.append(.updateAvailable(title: "Update to version \(newerVersion)"))
- } else if isCheckingForUpdates {
- entries.append(.disabled("Checking for Updates…"))
} else {
entries.append(.checkForUpdates(title: "Check for Updates"))
}
+ entries.append(
+ .moreOptions(
+ showStartAtLogin: showLaunchAtLogin,
+ startAtLoginEnabled: launchAtLoginEnabled,
+ automaticUpdatesEnabled: automaticUpdatesEnabled))
entries.append(.quit(title: "Quit"))
return entries
@@ -141,8 +125,6 @@ public enum StatusMenuModel {
switch outcome {
case .cancelled:
return "mount attempt cancelled at the password dialog"
- case .cliNotFound:
- return "mount attempt failed: bundled CLI missing"
case .error(let message):
return "mount attempt failed: \(message)"
case .report(let counts):
@@ -153,7 +135,7 @@ public enum StatusMenuModel {
/// One line for the diagnostic report's event log, recording what the
/// unprivileged first pass managed on its own. `fail` here is not a real
/// failure — those volumes go on to the escalated pass.
- public static func unprivilegedMountEventLine(for counts: CLIReport.Counts) -> String {
+ public static func unprivilegedMountEventLine(for counts: MountReport.Counts) -> String {
"unprivileged mount pass: \(counts.ok) mounted, \(counts.fail) need elevation, \(counts.skip) skipped"
}
@@ -167,7 +149,7 @@ public enum StatusMenuModel {
///
/// `escalated` is nil when the first pass left nothing for root to do.
public static func combinedOutcome(
- unprivileged: CLIReport.Counts,
+ unprivileged: MountReport.Counts,
escalated: EscalatedMountOutcome?
) -> EscalatedMountOutcome {
guard let escalated else { return .report(unprivileged) }
@@ -175,11 +157,11 @@ public enum StatusMenuModel {
case .report(let counts):
return .report(.init(ok: unprivileged.ok + counts.ok, fail: counts.fail, skip: counts.skip))
case .cancelled:
- // Dismissing the dialog declines the rest rather than failing at
- // it; whatever mounted before the prompt still counts.
- guard unprivileged.ok > 0 else { return .cancelled }
- return .report(.init(ok: unprivileged.ok))
- case .cliNotFound, .error:
+ // Declining the dialog leaves the remaining volumes unmounted —
+ // that is a failure of the mount attempt, reported alongside
+ // whatever mounted before the prompt.
+ return .report(.init(ok: unprivileged.ok, fail: unprivileged.fail))
+ case .error:
// The escalation never ran, so the first pass's failures stand.
guard unprivileged.ok > 0 else { return escalated }
return .report(.init(ok: unprivileged.ok, fail: unprivileged.fail))
@@ -187,16 +169,19 @@ public enum StatusMenuModel {
}
/// Body of the user-facing notification for a finished mount attempt, or
- /// nil when the outcome warrants none (user dismissed the password dialog).
+ /// nil when the outcome warrants none.
public static func notificationBody(for outcome: EscalatedMountOutcome) -> String? {
switch outcome {
case .cancelled:
+ // Unreachable from the app flow: combinedOutcome folds a cancel
+ // into a report whose failures carry the notification.
return nil
- case .cliNotFound:
- return "Bundled CLI is missing — please reinstall the app."
case .report(let counts):
if counts.fail > 0 {
- return "Mount failed for \(counts.fail) volume(s) — try `lifsaver --verbose` in Terminal."
+ // With several volumes in play a bare "failed" would hide the
+ // partial result — summarise both halves.
+ guard counts.ok + counts.fail + counts.skip > 1 else { return "Mount failed" }
+ return "Mount failed (\(counts.ok) mounted, \(counts.fail) failed)"
}
if counts.ok > 0 {
let noun = counts.ok == 1 ? "volume" : "volumes"
@@ -204,7 +189,7 @@ public enum StatusMenuModel {
}
return "Nothing mounted — volumes were skipped (already mounted or being checked)."
case .error:
- return "Mount failed — try `lifsaver --verbose` in Terminal."
+ return "Mount failed"
}
}
}
diff --git a/Sources/LifsaverCore/UpdateChecker.swift b/Sources/LifsaverKit/UpdateChecker.swift
similarity index 66%
rename from Sources/LifsaverCore/UpdateChecker.swift
rename to Sources/LifsaverKit/UpdateChecker.swift
index 72a2007..1bdbfa2 100644
--- a/Sources/LifsaverCore/UpdateChecker.swift
+++ b/Sources/LifsaverKit/UpdateChecker.swift
@@ -3,22 +3,14 @@ import os
// Portable GitHub-releases update checker.
//
-// Checks the repository's latest release for a newer version and prints an
-// upgrade hint to stderr:
-//
-// Update available! Run: brew upgrade --cask lifsaver
+// Checks the repository's latest release for a newer version and surfaces it
+// through knownNewerVersion() (the app's "Update to version …" menu item).
//
// Behaviour:
-// - never crashes or slows down the host CLI: every failure is silent,
-// and the network fetch runs in a background task that notify() waits
-// on only briefly
+// - never crashes or slows down the host process: every failure is silent,
+// and the network fetch runs in a background task
// - at most one API request per `checkInterval` (result cached
// private-to-the-user in ~/Library/Caches/)
-// - `{latest}` in `upgradeCommand` is replaced with the newest known
-// version when the hint is printed (for direct download links)
-// - hints only appear on interactive runs (stderr is a tty)
-// - colour follows the NO_COLOR / FORCE_COLOR conventions (no-color.org)
-// - opt out with LIFSAVER_NO_UPDATE_CHECK=1, NO_UPDATE_CHECK=1, or in CI
// ---------------------------------------------------------------------------
// Version comparison
@@ -149,10 +141,19 @@ public protocol LatestReleaseFetching: Sendable {
}
public struct GitHubReleaseFetcher: LatestReleaseFetching {
+ /// A hostile or misbehaving server (captive portal, proxy) must not balloon
+ /// memory inside a long-lived menu bar app: the body is streamed and
+ /// abandoned at this cap, never buffered whole before checking.
+ static let maxResponseBytes = 1 << 20
+
public init() {}
public func fetchLatestTag(repo: String, userAgent: String, timeout: TimeInterval) async -> String? {
- guard let url = URL(string: "https://api.github.com/repos/\(repo)/releases/latest") else {
+ // Not /releases/latest: GitHub defines that as the newest release NOT
+ // marked prerelease (404 when only prereleases exist), which would
+ // blind the checker to this project's beta releases. The newest release
+ // of any kind is the first element of /releases.
+ guard let url = URL(string: "https://api.github.com/repos/\(repo)/releases?per_page=1") else {
return nil
}
var request = URLRequest(url: url, timeoutInterval: timeout)
@@ -160,54 +161,61 @@ public struct GitHubReleaseFetcher: LatestReleaseFetching {
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
guard
- let (data, response) = try? await URLSession.shared.data(for: request),
+ let (bytes, response) = try? await URLSession.shared.bytes(for: request),
let http = response as? HTTPURLResponse, http.statusCode == 200,
- data.count <= 1 << 20,
- let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- let tagName = object["tag_name"] as? String
+ let data = try? await Self.collect(bytes, limit: Self.maxResponseBytes),
+ let releases = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
+ let tagName = releases.first?["tag_name"] as? String
else { return nil }
return tagName
}
+
+ private static func collect(_ bytes: URLSession.AsyncBytes, limit: Int) async throws -> Data {
+ var data = Data()
+ for try await byte in bytes {
+ data.append(byte)
+ guard data.count <= limit else { throw URLError(.dataLengthExceedsMaximum) }
+ }
+ return data
+ }
}
// ---------------------------------------------------------------------------
// Checker
// ---------------------------------------------------------------------------
+/// Result of a user-initiated "Check for Updates". Unlike the passive launch
+/// check, the manual action must always tell the user what happened.
+public enum ManualCheckOutcome: Equatable, Sendable {
+ /// The fetch succeeded and a strictly newer release exists.
+ case updateAvailable(String)
+ /// The fetch succeeded and the current version is the newest.
+ case upToDate
+ /// The fetch failed (offline, GitHub unreachable, no releases yet, …).
+ case failed
+}
+
public final class UpdateChecker: Sendable {
let package: String
let repo: String
let currentVersion: String
- let upgradeCommand: String
let checkInterval: TimeInterval
let cacheFile: URL
let fetcher: any LatestReleaseFetching
- let environment: [String: String]
- let stderrIsInteractive: @Sendable () -> Bool
- let emit: @Sendable (String) -> Void
private let latestBox = OSAllocatedUnfairLock(initialState: nil)
- /// Finishes when the check settles (fetch done, cache hit, or disabled),
- /// so notify() can wait for an in-flight fetch with a hard cap.
- private let fetchSettled: AsyncStream
- private let fetchSettledContinuation: AsyncStream.Continuation
public init(
package: String,
repo: String,
currentVersion: String,
- upgradeCommand: String,
checkInterval: TimeInterval = 24 * 60 * 60,
cacheDirectory: URL? = nil,
- fetcher: any LatestReleaseFetching = GitHubReleaseFetcher(),
- environment: [String: String] = ProcessInfo.processInfo.environment,
- stderrIsInteractive: @escaping @Sendable () -> Bool = { isatty(STDERR_FILENO) != 0 },
- emit: (@Sendable (String) -> Void)? = nil
+ fetcher: any LatestReleaseFetching = GitHubReleaseFetcher()
) {
self.package = package
self.repo = repo
self.currentVersion = currentVersion
- self.upgradeCommand = upgradeCommand
self.checkInterval = checkInterval
let cacheBase =
cacheDirectory
@@ -216,66 +224,22 @@ public final class UpdateChecker: Sendable {
.appendingPathComponent(package)
cacheFile = cacheBase.appendingPathComponent("update-check.json")
self.fetcher = fetcher
- self.environment = environment
- self.stderrIsInteractive = stderrIsInteractive
- self.emit = emit ?? { FileHandle.standardError.write(Data(($0 + "\n").utf8)) }
- (fetchSettled, fetchSettledContinuation) = AsyncStream.makeStream(of: Void.self)
}
// --- lifecycle ---------------------------------------------------------
- /// Begin the check. Instant: either reads a fresh cache or forks a fetch.
- public func start() {
- guard enabled() else {
- fetchSettledContinuation.finish()
- return
- }
+ /// Begin the check. Instant: either reads a fresh cache or forks a fetch,
+ /// returning the fetch task so tests can await it landing.
+ @discardableResult
+ public func start() -> Task? {
+ // Dev build / unknown version: cannot compare, stay quiet.
+ guard SemanticVersion(currentVersion) != nil else { return nil }
if let cached = readCache() {
latestBox.withLock { $0 = cached }
- fetchSettledContinuation.finish()
- return
- }
- Task(priority: .utility) { [self] in
- defer { fetchSettledContinuation.finish() }
- guard
- let tag = await fetcher.fetchLatestTag(
- repo: repo,
- userAgent: "\(package)/\(currentVersion) (update-check)",
- timeout: 5
- )
- else { return }
- let version = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag
- latestBox.withLock { $0 = version }
- writeCache(version)
- }
- }
-
- /// Print the upgrade hint to stderr if a newer release is known.
- ///
- /// Waits at most `timeout` seconds for an in-flight fetch — long enough
- /// for a warm connection, short enough to be imperceptible. A fetch that
- /// misses the window is simply not reported this run.
- public func notify(timeout: TimeInterval = 0.25) async {
- // Race the settle signal against a deadline; both branches respond
- // to cancellation, so the group ends as soon as either finishes.
- await withTaskGroup(of: Void.self) { group in
- group.addTask { [fetchSettled] in
- for await _ in fetchSettled {}
- }
- group.addTask {
- try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
- }
- await group.next()
- group.cancelAll()
+ return nil
}
-
- guard let known = latestBox.withLock({ $0 }), isNewer(latest: known, current: currentVersion) else { return }
- let command = upgradeCommand.replacingOccurrences(of: "{latest}", with: known)
- if stderrSupportsColor() {
- // BOLD stacks on ORANGE, so the command renders bold orange.
- emit("\u{1B}[38;5;208mUpdate available! Run: \u{1B}[1m\(command)\u{1B}[0m")
- } else {
- emit("Update available! Run: \(command)")
+ return Task(priority: .utility) { [self] in
+ await refresh()
}
}
@@ -288,43 +252,36 @@ public final class UpdateChecker: Sendable {
return known
}
- /// Force a fresh check now, ignoring the cache and the interactive/opt-out
- /// gates that only govern the passive CLI hint — the user asked for this.
- /// Refreshes the known version and cache; the result is then reflected by
- /// `knownNewerVersion()`. For the GUI "Check for Updates" menu action.
- public func checkNow() async {
+ /// Force a fresh check now, ignoring the cache and the version gate that
+ /// only governs the passive launch check — the user asked for this.
+ /// Refreshes the known version and cache and reports the outcome so the GUI
+ /// can acknowledge the manual "Check for Updates" action; a newer version
+ /// is also reflected by `knownNewerVersion()`.
+ public func checkNow() async -> ManualCheckOutcome {
+ guard await refresh() else { return .failed }
+ if let newer = knownNewerVersion() { return .updateAvailable(newer) }
+ return .upToDate
+ }
+
+ // --- internals ---------------------------------------------------------
+
+ /// Fetch the newest release tag, normalize it, and publish it to both the
+ /// in-memory box and the on-disk cache. Single body shared by the passive
+ /// launch check and the manual menu check so the two can never drift.
+ /// Returns whether the fetch succeeded; the passive path ignores it.
+ @discardableResult
+ private func refresh() async -> Bool {
guard
let tag = await fetcher.fetchLatestTag(
repo: repo,
userAgent: "\(package)/\(currentVersion) (update-check)",
timeout: 5
)
- else { return }
+ else { return false }
let version = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag
latestBox.withLock { $0 = version }
writeCache(version)
- }
-
- // --- internals ---------------------------------------------------------
-
- func enabled() -> Bool {
- let prefix = package.uppercased().map { $0.isLetter || $0.isNumber ? $0 : "_" }
- let optOuts = ["\(String(prefix))_NO_UPDATE_CHECK", "NO_UPDATE_CHECK", "CI"]
- // Any non-empty value opts out — including "0" and "false" — matching
- // how CI-style flags are conventionally treated.
- if optOuts.contains(where: { !(environment[$0] ?? "").isEmpty }) {
- return false
- }
- if SemanticVersion(currentVersion) == nil { // dev build / unknown version
- return false
- }
- return stderrIsInteractive()
- }
-
- func stderrSupportsColor() -> Bool {
- if !(environment["NO_COLOR"] ?? "").isEmpty { return false }
- if !(environment["FORCE_COLOR"] ?? "").isEmpty { return true }
- return stderrIsInteractive()
+ return true
}
func readCache() -> String? {
@@ -332,9 +289,12 @@ public final class UpdateChecker: Sendable {
let data = try? Data(contentsOf: cacheFile),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let checkedAt = object["checked_at"] as? Double,
- let cachedLatest = object["latest"] as? String,
- Date().timeIntervalSince1970 - checkedAt < checkInterval
+ let cachedLatest = object["latest"] as? String
else { return nil }
+ // A future checked_at (clock skew, NTP jump) must read as stale, not as
+ // "fresh until the wall clock catches up".
+ let elapsed = Date().timeIntervalSince1970 - checkedAt
+ guard elapsed >= 0, elapsed < checkInterval else { return nil }
return cachedLatest
}
diff --git a/Sources/LifsaverCore/Version.swift b/Sources/LifsaverKit/Version.swift
similarity index 100%
rename from Sources/LifsaverCore/Version.swift
rename to Sources/LifsaverKit/Version.swift
diff --git a/Sources/lifsaver/LifsaverCommand.swift b/Sources/lifsaver/LifsaverCommand.swift
deleted file mode 100644
index 38408cd..0000000
--- a/Sources/lifsaver/LifsaverCommand.swift
+++ /dev/null
@@ -1,237 +0,0 @@
-import ArgumentParser
-import Foundation
-import LifsaverCore
-
-/// Force-mount external camera data volumes stuck in macOS Disk Utility limbo.
-/// Bypasses automated daemon naming race conditions.
-///
-/// macOS Tahoe / LIFS compatibility: prefers `diskutil mount` over raw mount
-/// binaries, which are increasingly sandbox-restricted in Tahoe's security model.
-///
-/// Usage:
-/// lifsaver # scan (read-only), confirm, then mount via sudo
-@main
-struct LifsaverCommand: AsyncParsableCommand {
- static let configuration = CommandConfiguration(
- commandName: "lifsaver",
- abstract: "Force-mount stalled 'Untitled' volumes on macOS.",
- version: lifsaverVersion,
- subcommands: [ReportCommand.self]
- )
-
- @Flag(help: "Show the full mount sequence and raw stderr from mount commands.")
- var verbose = false
-
- @Flag(help: .hidden) // machine-readable output on stdout (used by the menu bar app)
- var json = false
-
- mutating func run() async throws {
- let checker = UpdateChecker(
- package: "lifsaver",
- repo: "lucuma13/lifsaver",
- currentVersion: lifsaverVersion,
- upgradeCommand: lifsaverUpgradeCommand()
- )
- checker.start()
- let status = await CLIMain.run(verbose: verbose, json: json)
- await checker.notify()
- if status != 0 {
- throw ExitCode(status)
- }
- }
-}
-
-/// `lifsaver report` — save a diagnostic report for filing a bug. Read-only
-/// and root-free; writes the report to ~/Downloads.
-struct ReportCommand: AsyncParsableCommand {
- static let configuration = CommandConfiguration(
- commandName: "report",
- abstract: "Save a diagnostic report (read-only) to ~/Downloads to attach to a bug report."
- )
-
- mutating func run() async throws {
- let status = await ReportCLI.run()
- if status != 0 {
- throw ExitCode(status)
- }
- }
-}
-
-enum ReportCLI {
- static func run(
- runner: any ProcessRunning = DefaultProcessRunner(),
- mountTable: any MountTableReading = LiveMountTable(),
- directory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Downloads"),
- write: (String, URL) throws -> Void = { try $0.write(to: $1, atomically: true, encoding: .utf8) },
- emit: (String) -> Void = { print($0) },
- emitError: (String) -> Void = { Console.standard.err($0) }
- ) async -> Int32 {
- let reporter = DiagnosticsReporter(runner: runner, mountTable: mountTable)
- let report = await reporter.generate()
- let url = directory.appendingPathComponent(DiagnosticsReporter.suggestedFilename())
- do {
- try write(report, url)
- } catch {
- emitError("Could not save the diagnostic report: \(error)")
- return 1
- }
- emit("Diagnostic report exported to \(url.path). Please email it to \(lifsaverSupportEmail).")
- return 0
- }
-}
-
-enum CLIMain {
- static func run(
- verbose: Bool,
- json: Bool,
- runner: any ProcessRunning = DefaultProcessRunner(),
- mountTable: any MountTableReading = LiveMountTable(),
- console explicitConsole: Console? = nil,
- fileOps: any FileOperating = DefaultFileOperations(),
- uid: () -> uid_t = { getuid() },
- emit: (String) -> Void = { print($0) },
- escalate: () -> Int32 = { escalateViaSudo() }
- ) async -> Int32 {
- // In JSON mode stdout carries only the JSON document; every human-facing
- // line is diverted to stderr.
- let console =
- explicitConsole
- ?? (json
- ? Console(out: Console.standard.err, err: Console.standard.err)
- : .standard)
- let scanner = DiskScanner(runner: runner, mountTable: mountTable, console: console, verbose: verbose)
-
- // Running `sudo lifsaver` directly skips the confirmation prompt — the
- // sudo re-exec in the pre-flight would otherwise ask twice.
- if uid() != 0 {
- return await preflight(scanner: scanner, json: json, emit: emit, escalate: escalate)
- }
- return await mountSequence(mounter: Mounter(scanner: scanner, fileOps: fileOps), json: json, emit: emit)
- }
-
- /// Non-root pre-flight: scanning is read-only, so find the targets first and
- /// describe them before escalating. sudo's own password prompt acts as the
- /// confirmation — Ctrl+C or a wrong password there aborts with nothing mounted.
- private static func preflight(
- scanner: DiskScanner, json: Bool,
- emit: (String) -> Void, escalate: () -> Int32
- ) async -> Int32 {
- let targets: [String]
- do {
- targets = try await scanner.scanTargets()
- } catch {
- scanner.console.err("CRITICAL: \(error)")
- return 1
- }
-
- if json {
- // Read-only JSON scan: report targets and stop — never escalate
- // from JSON mode; the caller decides what to do next.
- emitJSON(CLIReport(action: .scan, targets: targets), emit: emit)
- return 0
- }
-
- guard !targets.isEmpty else {
- scanner.console.out("No stalled or unmounted camera data volumes detected.")
- return 0
- }
- let noun = targets.count == 1 ? "volume" : "volumes"
- scanner.console.out(
- "Would mount \(targets.count) stalled \(noun). If you want to continue, "
- + "please enter your password below (otherwise Ctrl+C to abort, or quit this window):"
- )
- return escalate()
- }
-
- /// Root path: scan again with fresh eyes, then mount everything found.
- private static func mountSequence(
- mounter: Mounter, json: Bool, emit: (String) -> Void
- ) async -> Int32 {
- let scanner = mounter.scanner
- let console = scanner.console
- let verbose = scanner.verbose
-
- if verbose {
- console.out(separatorLine)
- console.out("Camera volume mount sequence")
- console.out(separatorLine)
- }
-
- let targets: [String]
- do {
- targets = try await scanner.scanTargets()
- } catch {
- console.err("CRITICAL: \(error)")
- return 1
- }
-
- guard !targets.isEmpty else {
- if json {
- emitJSON(CLIReport(action: .mount, targets: [], results: .init(), mounted: []), emit: emit)
- } else {
- console.out("No stalled or unmounted camera data volumes detected.")
- if verbose {
- console.out(separatorLine)
- }
- }
- return 0
- }
-
- if verbose {
- console.out("Found \(targets.count) candidate volume(s): \(targets.joined(separator: ", "))")
- }
-
- let (results, mounted) = await mountAll(targets, mounter: mounter)
-
- if json {
- emitJSON(CLIReport(action: .mount, targets: targets, results: results, mounted: mounted), emit: emit)
- } else {
- console.out("Done — \(results.ok) mounted, \(results.fail) failed, \(results.skip) skipped.")
- }
- return results.fail > 0 ? 1 : 0
- }
-
- /// Mount every target in order, tallying outcomes for the report.
- private static func mountAll(
- _ targets: [String], mounter: Mounter
- ) async -> (results: CLIReport.Counts, mounted: [CLIReport.MountedVolume]) {
- let scanner = mounter.scanner
- var results = CLIReport.Counts()
- var mounted: [CLIReport.MountedVolume] = []
- for devId in targets {
- switch await mounter.execute(devId) {
- case .ok:
- results.ok += 1
- mounted.append(.init(device: devId, mountPoint: scanner.mountPoint(of: devId)))
- case .fail:
- results.fail += 1
- case .skip:
- results.skip += 1
- }
- if scanner.verbose {
- scanner.console.out(separatorLine)
- }
- }
- return (results, mounted)
- }
-
- /// Replace this process with `sudo ` via
- /// execvp(3). Never returns on success.
- static func escalateViaSudo() -> Never {
- let executable = Bundle.main.executablePath ?? CommandLine.arguments[0]
- let argv = ["sudo", executable] + CommandLine.arguments.dropFirst()
- var cArgv: [UnsafeMutablePointer?] = argv.map { strdup($0) }
- cArgv.append(nil)
- execvp("sudo", cArgv)
- // exec only returns on failure
- perror("lifsaver: could not exec sudo")
- exit(1)
- }
-
- private static func emitJSON(_ report: CLIReport, emit: (String) -> Void) {
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.sortedKeys]
- guard let data = try? encoder.encode(report) else { return }
- emit(String(decoding: data, as: UTF8.self))
- }
-}
diff --git a/Tests/LifsaverCLITests/CLISmokeTests.swift b/Tests/LifsaverCLITests/CLISmokeTests.swift
deleted file mode 100644
index a725aa5..0000000
--- a/Tests/LifsaverCLITests/CLISmokeTests.swift
+++ /dev/null
@@ -1,397 +0,0 @@
-import Foundation
-import Testing
-import os
-
-@testable import LifsaverCore
-@testable import lifsaver
-
-// ---------------------------------------------------------------------------
-// Local fakes (this target cannot see LifsaverCoreTests helpers)
-// ---------------------------------------------------------------------------
-
-private final class RecordingRunner: ProcessRunning {
- private let handler: @Sendable (String, [String]) throws -> ProcessResult
-
- init(handler: @escaping @Sendable (String, [String]) throws -> ProcessResult) {
- self.handler = handler
- }
-
- func run(_ executable: String, _ arguments: [String], timeout: TimeInterval) async throws -> ProcessResult {
- try handler(executable, arguments)
- }
-}
-
-private final class InMemoryMountTable: MountTableReading {
- private let state = OSAllocatedUnfairLock(initialState: [MountEntry]())
-
- func add(device: String, mountPoint: String) {
- state.withLock { $0.append(MountEntry(device: device, mountPoint: mountPoint)) }
- }
-
- func entries() throws -> [MountEntry] {
- state.withLock { $0 }
- }
-}
-
-private struct NoOpFileOperations: FileOperating {
- func createDirectory(at path: String) throws {}
- func removeEmptyDirectory(at path: String) {}
-}
-
-private final class Captured {
- // Console closures are @Sendable, so those sinks live in a locked box;
- // emitted/escalated are only touched by synchronous non-Sendable closures.
- private let lines = OSAllocatedUnfairLock(initialState: (out: [String](), err: [String]()))
- var emitted: [String] = []
- var escalated = false
-
- var out: [String] { lines.withLock { $0.out } }
- var err: [String] { lines.withLock { $0.err } }
-
- var console: Console {
- Console(
- out: { [lines] line in lines.withLock { $0.out.append(line) } },
- err: { [lines] line in lines.withLock { $0.err.append(line) } }
- )
- }
-
- var outText: String { out.joined(separator: "\n") }
- var errText: String { err.joined(separator: "\n") }
-}
-
-private func plist(_ object: Any) -> Data {
- // swiftlint:disable:next force_try
- try! PropertyListSerialization.data(fromPropertyList: object, format: .xml, options: 0)
-}
-
-/// diskutil plist with one external Microsoft Basic Data partition per device id.
-private func diskDataPlist(devices: [String]) -> Data {
- plist([
- "AllDisksAndPartitions": [
- [
- "DeviceIdentifier": "disk4",
- "Partitions": devices.map {
- ["DeviceIdentifier": $0, "Content": "Microsoft Basic Data"]
- },
- ]
- ]
- ])
-}
-
-/// Fake runner + mount table emulating a full scan-and-mount environment.
-private func scenario(
- devices: [String], diskutilMountSucceeds: Bool = true,
- diskutilListSucceeds: Bool = true, fsckActive: Bool = false
-) -> (runner: RecordingRunner, mountTable: InMemoryMountTable) {
- let mountTable = InMemoryMountTable()
- let runner = RecordingRunner { executable, arguments in
- switch executable {
- case "pgrep":
- // pgrep exits 1 when no fsck is running; a match lists the raw device.
- guard fsckActive else { return ProcessResult(status: 1) }
- let listing = devices.map { "501 fsck_exfat /dev/r\($0)" }.joined(separator: "\n")
- return ProcessResult(status: 0, stdout: Data(listing.utf8))
- case "diskutil" where arguments.first == "list":
- guard diskutilListSucceeds else { return ProcessResult(status: 1) }
- return ProcessResult(status: 0, stdout: diskDataPlist(devices: devices))
- case "diskutil" where arguments.first == "info" && arguments.last == "disk4":
- // Whole-disk externality query (hardware location lives only here).
- return ProcessResult(
- status: 0, stdout: plist(["Internal": false, "RemovableMediaOrExternalDevice": true]))
- case "diskutil" where arguments.first == "info":
- return ProcessResult(status: 0, stdout: plist(["FilesystemType": "exfat"]))
- case "diskutil" where arguments.first == "mount":
- if diskutilMountSucceeds {
- mountTable.add(device: "/dev/\(arguments[1])", mountPoint: "/Volumes/CARD")
- return ProcessResult(status: 0)
- }
- return ProcessResult(status: 1)
- default:
- return ProcessResult(status: 1)
- }
- }
- return (runner, mountTable)
-}
-
-private func runCLI(
- verbose: Bool = false,
- json: Bool = false,
- devices: [String],
- diskutilMountSucceeds: Bool = true,
- diskutilListSucceeds: Bool = true,
- fsckActive: Bool = false,
- uid: uid_t,
- captured: Captured
-) async -> Int32 {
- let (runner, mountTable) = scenario(
- devices: devices, diskutilMountSucceeds: diskutilMountSucceeds,
- diskutilListSucceeds: diskutilListSucceeds, fsckActive: fsckActive)
- return await CLIMain.run(
- verbose: verbose, json: json,
- runner: runner,
- mountTable: mountTable,
- console: captured.console,
- fileOps: NoOpFileOperations(),
- uid: { uid },
- emit: { captured.emitted.append($0) },
- escalate: {
- captured.escalated = true
- return 0
- }
- )
-}
-
-// ===========================================================================
-// Non-root pre-flight (sudo escalation)
-// ===========================================================================
-
-@Suite struct PreflightTests {
- @Test func noTargetsPrintsMessageWithoutEscalating() async {
- let captured = Captured()
- let status = await runCLI(devices: [], uid: 501, captured: captured)
- #expect(status == 0)
- #expect(!captured.escalated)
- #expect(captured.outText.contains("No stalled or unmounted camera data volumes detected."))
- }
-
- @Test func targetsPromptThenEscalate() async {
- let captured = Captured()
- _ = await runCLI(devices: ["disk4s1"], uid: 501, captured: captured)
- #expect(captured.escalated)
- #expect(captured.outText.contains("Would mount 1 stalled volume."))
- }
-
- @Test func preflightMessagePluralizesVolumeCount() async {
- let captured = Captured()
- _ = await runCLI(devices: ["disk4s1", "disk4s2"], uid: 501, captured: captured)
- #expect(captured.escalated)
- #expect(captured.outText.contains("Would mount 2 stalled volumes."))
- }
-
- @Test func jsonScanNeverEscalates() async {
- let captured = Captured()
- let status = await runCLI(json: true, devices: ["disk4s1"], uid: 501, captured: captured)
- #expect(status == 0)
- #expect(!captured.escalated)
- #expect(captured.emitted.joined().contains("\"targets\":[\"disk4s1\"]"))
- }
-
- @Test func failedScanReportsCriticalWithoutEscalating() async {
- let captured = Captured()
- let status = await runCLI(
- devices: ["disk4s1"], diskutilListSucceeds: false, uid: 501, captured: captured)
- #expect(status == 1)
- // A scan we cannot trust must never lead to a password prompt.
- #expect(!captured.escalated)
- #expect(captured.errText.contains("CRITICAL"))
- #expect(captured.errText.contains("Failed to query diskutil"))
- }
-}
-
-// ===========================================================================
-// Root mount sequence
-// ===========================================================================
-
-@Suite struct MountSequenceTests {
- @Test func rootDoesNotEscalate() async {
- let captured = Captured()
- _ = await runCLI(devices: [], uid: 0, captured: captured)
- #expect(!captured.escalated)
- #expect(captured.outText.contains("No stalled or unmounted camera data volumes detected."))
- }
-
- @Test func mountsEachTargetAndSummarizes() async {
- let captured = Captured()
- let status = await runCLI(verbose: true, devices: ["disk4s1", "disk4s2"], uid: 0, captured: captured)
- #expect(status == 0)
- #expect(captured.outText.contains("Found 2 candidate volume(s): disk4s1, disk4s2"))
- #expect(captured.outText.contains("Done — 2 mounted, 0 failed, 0 skipped."))
- }
-
- @Test func exitsNonZeroWhenAMountFails() async {
- let captured = Captured()
- let status = await runCLI(
- devices: ["disk4s1"], diskutilMountSucceeds: false, uid: 0, captured: captured)
- #expect(status == 1)
- #expect(captured.outText.contains("Done — 0 mounted, 1 failed, 0 skipped."))
- }
-
- @Test func jsonMountReportsResults() async {
- let captured = Captured()
- let status = await runCLI(json: true, devices: ["disk4s1"], uid: 0, captured: captured)
- #expect(status == 0)
- let json = captured.emitted.joined()
- #expect(json.contains("\"action\":\"mount\""))
- #expect(json.contains("\"ok\":1"))
- #expect(json.contains("\"mountPoint\":\"\\/Volumes\\/CARD\""))
- }
-
- @Test func failedScanReportsCriticalAndMountsNothing() async {
- let captured = Captured()
- let status = await runCLI(
- devices: ["disk4s1"], diskutilListSucceeds: false, uid: 0, captured: captured)
- #expect(status == 1)
- #expect(captured.errText.contains("CRITICAL"))
- #expect(!captured.outText.contains("Done —"))
- }
-
- @Test func activeFsckSkipsTargetRatherThanMounting() async {
- let captured = Captured()
- let status = await runCLI(devices: ["disk4s1"], fsckActive: true, uid: 0, captured: captured)
- // A skipped volume is not a failure — nothing was left in a bad state.
- #expect(status == 0)
- #expect(captured.outText.contains("consistency check (fsck)"))
- #expect(captured.outText.contains("Done — 0 mounted, 0 failed, 1 skipped."))
- }
-
- @Test func verboseEmptyScanStillPrintsBanner() async {
- let captured = Captured()
- let status = await runCLI(verbose: true, devices: [], uid: 0, captured: captured)
- #expect(status == 0)
- #expect(captured.outText.contains("Camera volume mount sequence"))
- #expect(captured.outText.contains("No stalled or unmounted camera data volumes detected."))
- }
-
- @Test func jsonMountWithNoTargetsEmitsEmptyReport() async throws {
- let captured = Captured()
- let status = await runCLI(json: true, devices: [], uid: 0, captured: captured)
- #expect(status == 0)
- // The app parses every run's output, so the empty case must still be
- // a well-formed mount report rather than a human-readable sentence.
- let report = try JSONDecoder().decode(CLIReport.self, from: Data(captured.emitted.joined().utf8))
- #expect(report.action == .mount)
- #expect(report.targets.isEmpty)
- #expect(report.mounted == [])
- #expect(report.results == CLIReport.Counts(ok: 0, fail: 0, skip: 0))
- }
-
- @Test func jsonMountRoundTripsThroughSharedReportType() async throws {
- // The app decodes this exact output as CLIReport — assert the full
- // document round-trips, not just substrings.
- let captured = Captured()
- _ = await runCLI(json: true, devices: ["disk4s1"], uid: 0, captured: captured)
- let report = try JSONDecoder().decode(CLIReport.self, from: Data(captured.emitted.joined().utf8))
- #expect(report.action == .mount)
- #expect(report.results == CLIReport.Counts(ok: 1, fail: 0, skip: 0))
- #expect(report.mounted == [CLIReport.MountedVolume(device: "disk4s1", mountPoint: "/Volumes/CARD")])
- }
-}
-
-// ===========================================================================
-// Default console wiring (real stdout/stderr)
-// ===========================================================================
-
-/// Redirect the process-wide stdout/stderr file descriptors to temp files for
-/// the duration of `body`, returning what each received.
-///
-/// This is the only way to exercise CLIMain's *default* console: passing a
-/// console explicitly short-circuits the very wiring under test.
-private func capturingStandardStreams(
- _ body: () async -> Void
-) async -> (out: String, err: String) {
- let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
- try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- let outURL = directory.appendingPathComponent("stdout")
- let errURL = directory.appendingPathComponent("stderr")
- FileManager.default.createFile(atPath: outURL.path, contents: nil)
- FileManager.default.createFile(atPath: errURL.path, contents: nil)
-
- let outFD = open(outURL.path, O_WRONLY)
- let errFD = open(errURL.path, O_WRONLY)
- let savedOut = dup(STDOUT_FILENO)
- let savedErr = dup(STDERR_FILENO)
- dup2(outFD, STDOUT_FILENO)
- dup2(errFD, STDERR_FILENO)
-
- await body()
-
- // print() is fully buffered when stdout is a file rather than a tty.
- fflush(stdout)
- fflush(stderr)
- dup2(savedOut, STDOUT_FILENO)
- dup2(savedErr, STDERR_FILENO)
- for descriptor in [savedOut, savedErr, outFD, errFD] { close(descriptor) }
-
- let out = (try? String(contentsOf: outURL, encoding: .utf8)) ?? ""
- let err = (try? String(contentsOf: errURL, encoding: .utf8)) ?? ""
- try? FileManager.default.removeItem(at: directory)
- return (out, err)
-}
-
-/// Serialized: redirecting stdout/stderr mutates process-global state, so these
-/// must not run alongside anything else writing to the real streams.
-@Suite(.serialized) struct DefaultConsoleTests {
- /// The menu bar app decodes stdout verbatim, so a stray human-readable line
- /// on stdout in --json mode is a parse failure for the app. Guards the
- /// `json ? Console(out: .err, err: .err) : .standard` wiring, which every
- /// other test bypasses by injecting a console.
- @Test func jsonModeKeepsStdoutPureAndDivertsProseToStderr() async throws {
- let (runner, mountTable) = scenario(devices: [])
- let (out, err) = await capturingStandardStreams {
- let status = await CLIMain.run(
- verbose: true, json: true,
- runner: runner, mountTable: mountTable,
- fileOps: NoOpFileOperations(),
- uid: { 0 },
- escalate: { 1 }
- )
- #expect(status == 0)
- }
-
- // Note: these streams also carry the test harness's own progress output,
- // since the redirect is process-wide and other suites run in parallel.
- // Assert on lifsaver's contribution rather than byte-purity.
- let reportLine = try #require(out.split(separator: "\n").first { $0.hasPrefix("{") })
- let report = try JSONDecoder().decode(CLIReport.self, from: Data(reportLine.utf8))
- #expect(report.action == .mount)
-
- // The verbose banner is prose: stderr only, never stdout.
- #expect(!out.contains("Camera volume mount sequence"))
- #expect(err.contains("Camera volume mount sequence"))
- }
-
- @Test func humanModeSendsProseToStdout() async {
- let (runner, mountTable) = scenario(devices: [])
- let (out, _) = await capturingStandardStreams {
- _ = await CLIMain.run(
- verbose: true, json: false,
- runner: runner, mountTable: mountTable,
- fileOps: NoOpFileOperations(),
- uid: { 0 },
- escalate: { 1 }
- )
- }
- #expect(out.contains("Camera volume mount sequence"))
- #expect(out.contains("No stalled or unmounted camera data volumes detected."))
- }
-}
-
-// ===========================================================================
-// Built binary smoke test
-// ===========================================================================
-
-@Suite struct BinarySmokeTests {
- private final class BundleMarker {}
-
- private var binaryURL: URL {
- // The test bundle sits in the same build products directory as the
- // compiled `lifsaver` executable.
- Bundle(for: BundleMarker.self).bundleURL
- .deletingLastPathComponent()
- .appendingPathComponent("lifsaver")
- }
-
- @Test func versionFlagPrintsBareVersionAndExitsZero() throws {
- let process = Process()
- process.executableURL = binaryURL
- process.arguments = ["--version"]
- let pipe = Pipe()
- process.standardOutput = pipe
- try process.run()
- let output = String(
- decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
- process.waitUntilExit()
- #expect(process.terminationStatus == 0)
- #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == lifsaverVersion)
- }
-}
diff --git a/Tests/LifsaverCLITests/ReportCLITests.swift b/Tests/LifsaverCLITests/ReportCLITests.swift
deleted file mode 100644
index 956de73..0000000
--- a/Tests/LifsaverCLITests/ReportCLITests.swift
+++ /dev/null
@@ -1,72 +0,0 @@
-import Foundation
-import Testing
-import os
-
-@testable import LifsaverCore
-@testable import lifsaver
-
-// ===========================================================================
-// `lifsaver report` subcommand plumbing
-// ===========================================================================
-
-private struct CannedRunner: ProcessRunning {
- func run(_ executable: String, _ arguments: [String], timeout: TimeInterval) async throws -> ProcessResult {
- switch (executable, arguments.first) {
- case ("diskutil", "list"):
- let plist = try PropertyListSerialization.data(
- fromPropertyList: [
- "AllDisksAndPartitions": [
- [
- "DeviceIdentifier": "disk4",
- "Partitions": [["DeviceIdentifier": "disk4s1", "Content": "Microsoft Basic Data"]],
- ]
- ]
- ], format: .xml, options: 0)
- return ProcessResult(status: 0, stdout: plist)
- case ("diskutil", "info"):
- let plist = try PropertyListSerialization.data(
- fromPropertyList: ["Internal": false, "FilesystemType": "exfat"],
- format: .xml, options: 0)
- return ProcessResult(status: 0, stdout: plist)
- default:
- return ProcessResult(status: 1) // pgrep: no fsck running
- }
- }
-}
-
-private struct EmptyMountTable: MountTableReading {
- func entries() throws -> [MountEntry] { [] }
-}
-
-@Suite struct ReportCLITests {
- @Test func savesOneCompleteReport() async throws {
- let written = OSAllocatedUnfairLock(initialState: [(String, URL)]())
- let emitted = OSAllocatedUnfairLock(initialState: [String]())
- let status = await ReportCLI.run(
- runner: CannedRunner(),
- mountTable: EmptyMountTable(),
- directory: URL(fileURLWithPath: "/tmp/lifsaver-test-downloads"),
- write: { contents, url in written.withLock { $0.append((contents, url)) } },
- emit: { line in emitted.withLock { $0.append(line) } },
- emitError: { _ in }
- )
- #expect(status == 0)
-
- let writes = written.withLock { $0 }
- #expect(writes.count == 1)
- let (report, url) = try #require(writes.first)
- #expect(url.deletingLastPathComponent().path == "/tmp/lifsaver-test-downloads")
- #expect(url.lastPathComponent.hasPrefix("lifsaver-report-"))
- #expect(report.contains("# lifsaver diagnostic report"))
- #expect(report.contains("target: disk4s1 (exfat, fsck idle)"))
- #expect(report.contains("## diskutil list -plist"))
- #expect(report.contains("--- disk4 ---"))
-
- // The exported-file message names the destination and the support address.
- let message = emitted.withLock { $0 }.joined(separator: "\n")
- #expect(message.contains("Diagnostic report exported to \(url.path)."))
- #expect(message.contains("email it to \(lifsaverSupportEmail)"))
- // The report itself never carries the address verbatim.
- #expect(!report.contains(lifsaverSupportEmail))
- }
-}
diff --git a/Tests/LifsaverCoreTests/ScannerTargetSelectionTests.swift b/Tests/LifsaverCoreTests/ScannerTargetSelectionTests.swift
deleted file mode 100644
index 6cd9441..0000000
--- a/Tests/LifsaverCoreTests/ScannerTargetSelectionTests.swift
+++ /dev/null
@@ -1,246 +0,0 @@
-import Foundation
-import Testing
-
-@testable import LifsaverCore
-
-// ===========================================================================
-// isExternalHardware
-// ===========================================================================
-
-@Suite struct IsExternalHardwareTests {
- @Test func externalBusIsExternal() async {
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- #expect(await makeScanner(runner: runner).isExternalHardware("disk4"))
- }
-
- @Test func internalFixedDiskIsNotExternal() async {
- let runner = diskutilRunner(info: ["disk0": infoInternalFixed])
- #expect(!(await makeScanner(runner: runner).isExternalHardware("disk0")))
- }
-
- @Test func removableMediaRescuesMisreportedInternal() async {
- // USB bridges can misreport Internal; RemovableMediaOrExternalDevice
- // is the second signal that still identifies the card reader.
- let runner = diskutilRunner(info: ["disk4": infoMisreportingBridge])
- #expect(await makeScanner(runner: runner).isExternalHardware("disk4"))
- }
-
- @Test func failsClosedOnMissingKeys() async {
- let runner = diskutilRunner(info: ["disk4": ["Content": "GUID_partition_scheme"]])
- #expect(!(await makeScanner(runner: runner).isExternalHardware("disk4")))
- }
-
- @Test func failsClosedOnDiskutilFailure() async {
- let runner = FakeProcessRunner(always: ProcessResult(status: 1))
- #expect(!(await makeScanner(runner: runner).isExternalHardware("disk4")))
- }
-
- @Test func failsClosedOnUnreadablePlist() async {
- let runner = FakeProcessRunner(always: ProcessResult(status: 0, stdout: Data("not a plist".utf8)))
- #expect(!(await makeScanner(runner: runner).isExternalHardware("disk4")))
- }
-}
-
-// ===========================================================================
-// filterTargetPartitions
-// ===========================================================================
-
-@Suite struct FilterTargetPartitionsTests {
- @Test func picksUpUnmountedExternalExfat() async {
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- let targets = await makeScanner(runner: runner)
- .filterTargetPartitions(diskutilPlistExternalExfat, activeMounts: [])
- #expect(targets == ["disk4s1"])
- }
-
- @Test func skipsInternalDisks() async {
- // Boot Camp scenario: an unmounted NTFS/FAT partition on the internal
- // fixed disk must never become a target.
- let runner = diskutilRunner(info: ["disk0": infoInternalFixed])
- let targets = await makeScanner(runner: runner)
- .filterTargetPartitions(diskutilPlistInternal, activeMounts: [])
- #expect(targets.isEmpty)
- }
-
- @Test func acceptsMisreportingBridge() async {
- let runner = diskutilRunner(info: ["disk4": infoMisreportingBridge])
- let targets = await makeScanner(runner: runner)
- .filterTargetPartitions(diskutilPlistExternalExfat, activeMounts: [])
- #expect(targets == ["disk4s1"])
- }
-
- @Test func failsClosedWhenExternalityUnknown() async {
- // No info response for disk4 → treated as internal.
- let targets = await makeScanner(runner: diskutilRunner())
- .filterTargetPartitions(diskutilPlistExternalExfat, activeMounts: [])
- #expect(targets.isEmpty)
- }
-
- @Test func skipsEFIPartition() async {
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- let targets = await makeScanner(runner: runner).filterTargetPartitions(diskutilPlistEFI, activeMounts: [])
- // disk4s1 = EFI (blocked), disk4s2 = Microsoft Basic Data (allowed)
- #expect(!targets.contains("disk4s1"))
- #expect(targets.contains("disk4s2"))
- }
-
- @Test func skipsAppleAPFSWithoutQueryingExternality() async {
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- let targets = await makeScanner(runner: runner).filterTargetPartitions(diskutilPlistAPFS, activeMounts: [])
- #expect(targets.isEmpty)
- // Content filtering alone rules the disk out — no subprocess needed.
- #expect(runner.calls.isEmpty)
- }
-
- @Test func skipsAlreadyMountedDevice() async {
- let captured = CapturedConsole()
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- let scanner = makeScanner(runner: runner, console: captured.console, verbose: true)
- let targets = await scanner.filterTargetPartitions(diskutilPlistExternalExfat, activeMounts: ["/dev/disk4s1"])
- #expect(targets.isEmpty)
- #expect(captured.outText.contains("already mounted"))
- }
-
- @Test func verboseExplainsContentSkips() async {
- // Diagnostic reports replay these lines; every rejection needs a why.
- let captured = CapturedConsole()
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- let scanner = makeScanner(runner: runner, console: captured.console, verbose: true)
- _ = await scanner.filterTargetPartitions(diskutilPlistEFI, activeMounts: [])
- #expect(captured.outText.contains("Skipping disk4s1 — system partition (EFI)."))
-
- _ = await scanner.filterTargetPartitions(
- [
- "AllDisksAndPartitions": [
- ["DeviceIdentifier": "disk6", "Partitions": [["DeviceIdentifier": "disk6s1"]]]
- ]
- ],
- activeMounts: [])
- #expect(captured.outText.contains("Skipping disk6s1 — Content (empty) is not camera-card-like."))
- }
-
- @Test func contentSkipsAreSilentByDefault() async {
- let captured = CapturedConsole()
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- let scanner = makeScanner(runner: runner, console: captured.console)
- _ = await scanner.filterTargetPartitions(diskutilPlistEFI, activeMounts: [])
- #expect(captured.outText.isEmpty)
- }
-
- @Test func alreadyMountedSkipIsSilentByDefault() async {
- let captured = CapturedConsole()
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- let scanner = makeScanner(runner: runner, console: captured.console)
- let targets = await scanner.filterTargetPartitions(diskutilPlistExternalExfat, activeMounts: ["/dev/disk4s1"])
- #expect(targets.isEmpty)
- #expect(captured.outText.isEmpty)
- }
-
- @Test func multiDiskMultiPartition() async {
- // disk4s1=EFI(skip), disk4s2=MBD(ok), disk4s3=DOS_FAT_32(ok),
- // disk4s4=Windows_NTFS(ok — exFAT and NTFS share MBR type 0x07),
- // disk5: personality-name variants; only allowlisted spellings match
- let runner = diskutilRunner(info: ["disk4": infoExternal, "disk5": infoExternal])
- let targets = await makeScanner(runner: runner).filterTargetPartitions(diskutilPlistMulti, activeMounts: [])
- #expect(!targets.contains("disk4s1"))
- #expect(targets.contains("disk4s2"))
- #expect(targets.contains("disk4s3"))
- #expect(targets.contains("disk4s4"))
- #expect(targets.contains("disk5s1"))
- #expect(targets.contains("disk5s2"))
- #expect(!targets.contains("disk5s3"))
- }
-
- @Test func emptyDiskDataReturnsEmpty() async {
- let targets = await makeScanner().filterTargetPartitions(["AllDisksAndPartitions": [Any]()], activeMounts: [])
- #expect(targets.isEmpty)
- }
-
- @Test func partitionMissingDeviceIdentifierIsSkipped() async {
- let data: [String: Any] = [
- "AllDisksAndPartitions": [
- [
- "DeviceIdentifier": "disk4",
- "Partitions": [["Content": "Microsoft Basic Data"]], // no DeviceIdentifier
- ]
- ]
- ]
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: [])
- #expect(targets.isEmpty)
- }
-
- @Test func diskMissingDeviceIdentifierIsSkipped() async {
- // Candidate partitions but no whole-disk id to verify → fail closed.
- let data: [String: Any] = [
- "AllDisksAndPartitions": [
- [
- "Partitions": [["DeviceIdentifier": "disk4s1", "Content": "Microsoft Basic Data"]]
- ]
- ]
- ]
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: [])
- #expect(targets.isEmpty)
- }
-
- @Test(arguments: [
- "Microsoft Basic Data",
- "DOS_FAT_32",
- "Windows_FAT_32",
- "Windows_NTFS",
- "exFAT",
- "ExFAT",
- ])
- func allAllowlistedContentTypesAreAccepted(contentType: String) async {
- let data: [String: Any] = [
- "AllDisksAndPartitions": [
- [
- "DeviceIdentifier": "disk4",
- "Partitions": [["DeviceIdentifier": "disk4s1", "Content": contentType]],
- ]
- ]
- ]
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: [])
- #expect(targets.contains("disk4s1"))
- }
-
- @Test(arguments: [
- "Apple_APFS",
- "Apple_HFS",
- "Apple_Boot",
- "Apple_Recovery",
- "Apple_CoreStorage",
- "EFI",
- ])
- func allBlocklistedContentTypesAreRejected(contentType: String) async {
- let data: [String: Any] = [
- "AllDisksAndPartitions": [
- [
- "DeviceIdentifier": "disk4",
- "Partitions": [["DeviceIdentifier": "disk4s1", "Content": contentType]],
- ]
- ]
- ]
- let runner = diskutilRunner(info: ["disk4": infoExternal])
- let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: [])
- #expect(targets.isEmpty)
- }
-}
-
-// ===========================================================================
-// scanTargets (scan pipeline: diskutil plist + fresh mount table)
-// ===========================================================================
-
-@Suite struct ScanTargetsTests {
- @Test func excludesDevicesAlreadyInMountTable() async throws {
- let runner = diskutilRunner(
- list: diskutilPlistMulti,
- info: ["disk4": infoExternal, "disk5": infoExternal])
- let table = FakeMountTable([MountEntry(device: "/dev/disk4s2", mountPoint: "/Volumes/CARD")])
- let targets = try await makeScanner(runner: runner, mountTable: table).scanTargets()
- #expect(!targets.contains("disk4s2"))
- #expect(targets.contains("disk4s3"))
- }
-}
diff --git a/Tests/LifsaverCoreTests/ScannerTests.swift b/Tests/LifsaverCoreTests/ScannerTests.swift
deleted file mode 100644
index 428390e..0000000
--- a/Tests/LifsaverCoreTests/ScannerTests.swift
+++ /dev/null
@@ -1,224 +0,0 @@
-import Foundation
-import Testing
-
-@testable import LifsaverCore
-
-// ===========================================================================
-// LiveMountTable (real getmntinfo — read-only, safe in CI)
-// ===========================================================================
-
-@Suite struct LiveMountTableTests {
- @Test func liveTableContainsRootFilesystem() throws {
- let entries = try LiveMountTable().entries()
- #expect(entries.contains { $0.mountPoint == "/" })
- #expect(entries.contains { $0.device.hasPrefix("/dev/disk") })
- }
-}
-
-// ===========================================================================
-// activeMounts
-// ===========================================================================
-
-@Suite struct ActiveMountsTests {
- @Test func collectsDevEntries() {
- let table = FakeMountTable([
- MountEntry(device: "/dev/disk1s1", mountPoint: "/"),
- MountEntry(device: "/dev/disk3s1", mountPoint: "/Volumes/NO_NAME"),
- ])
- let result = makeScanner(mountTable: table).activeMounts()
- #expect(result == ["/dev/disk1s1", "/dev/disk3s1"])
- }
-
- @Test func ignoresVirtualFilesystems() {
- let table = FakeMountTable([
- MountEntry(device: "devfs", mountPoint: "/dev"),
- MountEntry(device: "map auto_home", mountPoint: "/System/Volumes/Data/home"),
- MountEntry(device: "/dev/disk1s1", mountPoint: "/"),
- ])
- let result = makeScanner(mountTable: table).activeMounts()
- #expect(result == ["/dev/disk1s1"])
- }
-
- @Test func returnsEmptySetOnFailure() {
- let captured = CapturedConsole()
- let table = FakeMountTable(throwing: POSIXError(.EIO))
- let result = makeScanner(mountTable: table, console: captured.console).activeMounts()
- #expect(result.isEmpty)
- #expect(captured.errText.contains("WARNING"))
- }
-
- @Test func returnsEmptySetOnEmptyTable() {
- #expect(makeScanner(mountTable: FakeMountTable()).activeMounts().isEmpty)
- }
-}
-
-// ===========================================================================
-// isCurrentlyMounted
-// ===========================================================================
-
-@Suite struct IsCurrentlyMountedTests {
- @Test func trueWhenPresent() {
- let table = FakeMountTable([MountEntry(device: "/dev/disk4s1", mountPoint: "/Volumes/X")])
- #expect(makeScanner(mountTable: table).isCurrentlyMounted("disk4s1"))
- }
-
- @Test func falseWhenAbsent() {
- let table = FakeMountTable([MountEntry(device: "/dev/disk1s1", mountPoint: "/")])
- #expect(!makeScanner(mountTable: table).isCurrentlyMounted("disk4s1"))
- }
-
- @Test func alwaysTakesFreshSnapshot() {
- // Must never rely on a cached set — each call must hit the mount table.
- let table = FakeMountTable()
- let scanner = makeScanner(mountTable: table)
- _ = scanner.isCurrentlyMounted("disk4s1")
- _ = scanner.isCurrentlyMounted("disk4s1")
- #expect(table.reads == 2)
- }
-}
-
-// ===========================================================================
-// mountPoint(of:)
-// ===========================================================================
-
-@Suite struct MountPointTests {
- @Test func extractsCorrectMountPoint() {
- let table = FakeMountTable([MountEntry(device: "/dev/disk4s1", mountPoint: "/Volumes/CARD")])
- #expect(makeScanner(mountTable: table).mountPoint(of: "disk4s1") == "/Volumes/CARD")
- }
-
- @Test func returnsEmptyStringWhenNotFound() {
- let table = FakeMountTable([MountEntry(device: "/dev/disk1s1", mountPoint: "/")])
- #expect(makeScanner(mountTable: table).mountPoint(of: "disk9s9").isEmpty)
- }
-
- @Test func returnsEmptyStringOnFailure() {
- let table = FakeMountTable(throwing: POSIXError(.EIO))
- #expect(makeScanner(mountTable: table).mountPoint(of: "disk4s1").isEmpty)
- }
-
- @Test func handlesMountPointWithSpaces() {
- let table = FakeMountTable([MountEntry(device: "/dev/disk4s1", mountPoint: "/Volumes/My Card")])
- #expect(makeScanner(mountTable: table).mountPoint(of: "disk4s1") == "/Volumes/My Card")
- }
-}
-
-// ===========================================================================
-// isFsckActive
-// ===========================================================================
-
-@Suite struct IsFsckActiveTests {
- private func scanner(pgrepOutput: String, status: Int32 = 0) -> DiskScanner {
- makeScanner(runner: FakeProcessRunner(always: ProcessResult(status: status, stdout: Data(pgrepOutput.utf8))))
- }
-
- @Test func trueWhenFsckTargetsDevice() async {
- let output = "812 /System/Library/Filesystems/exfat.fs/Contents/Resources/fsck_exfat -y /dev/rdisk4s1\n"
- #expect(await scanner(pgrepOutput: output).isFsckActive("disk4s1"))
- }
-
- @Test func matchesNonRawDeviceNode() async {
- #expect(await scanner(pgrepOutput: "812 fsck_msdos -y /dev/disk4s1\n").isFsckActive("disk4s1"))
- }
-
- @Test func falseWhenFsckTargetsOtherDevice() async {
- #expect(!(await scanner(pgrepOutput: "812 fsck_exfat -y /dev/rdisk5s1\n").isFsckActive("disk4s1")))
- }
-
- @Test func noFalseMatchOnLongerIdentifier() async {
- // disk4s1 must not match a check running on disk4s10.
- #expect(!(await scanner(pgrepOutput: "812 fsck_exfat -y /dev/rdisk4s10\n").isFsckActive("disk4s1")))
- }
-
- @Test func noFalseMatchInsideLongerToken() async {
- // disk4s1 must not match in the middle of an unrelated word like 'mydisk4s1'.
- #expect(!(await scanner(pgrepOutput: "812 fsck_exfat -y /dev/mydisk4s1\n").isFsckActive("disk4s1")))
- }
-
- @Test func falseWhenNoFsckRunning() async {
- #expect(!(await scanner(pgrepOutput: "", status: 1).isFsckActive("disk4s1")))
- }
-
- @Test func falseOnPgrepFailure() async {
- let runner = FakeProcessRunner(
- throwing: ProcessRunnerError.launchFailed(
- command: "pgrep", underlying: CocoaError(.fileNoSuchFile)))
- #expect(!(await makeScanner(runner: runner).isFsckActive("disk4s1")))
- }
-}
-
-// ===========================================================================
-// diskData
-// ===========================================================================
-
-@Suite struct DiskDataTests {
- @Test func returnsParsedPlist() async throws {
- let runner = FakeProcessRunner(
- always: ProcessResult(status: 0, stdout: plistData(diskutilPlistExternalExfat)))
- let data = try await makeScanner(runner: runner).diskData()
- #expect(data["AllDisksAndPartitions"] != nil)
- }
-
- @Test func throwsOnDiskutilFailure() async {
- let scanner = makeScanner(runner: FakeProcessRunner(always: ProcessResult(status: 1)))
- await #expect(throws: DiskUtilError.self) {
- try await scanner.diskData()
- }
- }
-
- @Test func throwsOnUnreadablePlist() async {
- let scanner = makeScanner(
- runner: FakeProcessRunner(always: ProcessResult(status: 0, stdout: Data("not a plist".utf8))))
- await #expect(throws: DiskUtilError.self) {
- try await scanner.diskData()
- }
- }
-}
-
-// ===========================================================================
-// partitionFSType
-// ===========================================================================
-
-@Suite struct PartitionFSTypeTests {
- @Test func returnsFilesystemTypeLowercase() async {
- let info: [String: Any] = ["FilesystemType": "ExFAT", "Content": "Microsoft Basic Data"]
- let runner = FakeProcessRunner(always: ProcessResult(status: 0, stdout: plistData(info)))
- #expect(await makeScanner(runner: runner).partitionFSType("disk4s1") == "exfat")
- }
-
- @Test func fallsBackToContentWhenNoFilesystemType() async {
- let info: [String: Any] = ["Content": "DOS_FAT_32"]
- let runner = FakeProcessRunner(always: ProcessResult(status: 0, stdout: plistData(info)))
- #expect(await makeScanner(runner: runner).partitionFSType("disk4s1") == "dos_fat_32")
- }
-
- @Test func fallsBackToContentWhenFilesystemTypeEmpty() async {
- let info: [String: Any] = ["FilesystemType": "", "Content": "DOS_FAT_32"]
- let runner = FakeProcessRunner(always: ProcessResult(status: 0, stdout: plistData(info)))
- #expect(await makeScanner(runner: runner).partitionFSType("disk4s1") == "dos_fat_32")
- }
-
- @Test func returnsEmptyStringOnFailure() async {
- let runner = FakeProcessRunner(
- throwing: ProcessRunnerError.launchFailed(
- command: "diskutil", underlying: CocoaError(.fileNoSuchFile)))
- #expect(await makeScanner(runner: runner).partitionFSType("disk4s1").isEmpty)
- }
-
- @Test func returnsEmptyStringWhenBothKeysMissing() async {
- let runner = FakeProcessRunner(
- always: ProcessResult(status: 0, stdout: plistData(["SomeOtherKey": "value"])))
- #expect(await makeScanner(runner: runner).partitionFSType("disk4s1").isEmpty)
- }
-}
-
-// ===========================================================================
-// Version
-// ===========================================================================
-
-@Suite struct VersionTests {
- @Test func versionIsANonEmptySemanticVersion() {
- #expect(!lifsaverVersion.isEmpty)
- #expect(SemanticVersion(lifsaverVersion) != nil)
- }
-}
diff --git a/Tests/LifsaverKitTests/ConsoleTests.swift b/Tests/LifsaverKitTests/ConsoleTests.swift
new file mode 100644
index 0000000..bda6db6
--- /dev/null
+++ b/Tests/LifsaverKitTests/ConsoleTests.swift
@@ -0,0 +1,50 @@
+import Foundation
+import Testing
+
+@testable import LifsaverKit
+
+// ===========================================================================
+// Live console log
+// ===========================================================================
+
+@Suite struct ConsoleLogTests {
+ @Test func recordedLinesCarryTimestamps() {
+ let log = ConsoleLog(now: { Date(timeIntervalSince1970: 0) })
+ log.record("Target: /dev/disk4s1")
+ #expect(log.snapshot() == ["1970-01-01T00:00:00Z Target: /dev/disk4s1"])
+ }
+
+ @Test func teeConsoleRecordsAndForwardsBothStreams() {
+ let log = ConsoleLog(now: { Date(timeIntervalSince1970: 0) })
+ let wrapped = CapturedConsole()
+ let console = log.console(alsoTo: wrapped.console)
+
+ console.out("attempting mount")
+ console.err("mount stderr")
+
+ #expect(wrapped.out == ["attempting mount"])
+ #expect(wrapped.err == ["mount stderr"])
+ #expect(
+ log.snapshot() == [
+ "1970-01-01T00:00:00Z attempting mount",
+ "1970-01-01T00:00:00Z mount stderr",
+ ])
+ }
+
+ @Test func oldestLinesRollOffPastCapacity() {
+ // The disk watcher rescans on every disk event for as long as the app
+ // stays resident; the buffer must stay bounded and keep the newest.
+ let log = ConsoleLog(capacity: 3)
+ log.append(["1", "2"])
+ log.append(["3", "4", "5"])
+ #expect(log.snapshot() == ["3", "4", "5"])
+ }
+
+ @Test func appendMergesForeignLinesVerbatim() {
+ // The root helper's lines arrive already timestamped by its own log;
+ // merging must not re-stamp them.
+ let log = ConsoleLog()
+ log.append(["--- escalated helper (root) ---", "already stamped line"])
+ #expect(log.snapshot() == ["--- escalated helper (root) ---", "already stamped line"])
+ }
+}
diff --git a/Tests/LifsaverCoreTests/DiagnosticsReportTests.swift b/Tests/LifsaverKitTests/DiagnosticsReportTests.swift
similarity index 52%
rename from Tests/LifsaverCoreTests/DiagnosticsReportTests.swift
rename to Tests/LifsaverKitTests/DiagnosticsReportTests.swift
index 352775b..f17bb16 100644
--- a/Tests/LifsaverCoreTests/DiagnosticsReportTests.swift
+++ b/Tests/LifsaverKitTests/DiagnosticsReportTests.swift
@@ -1,7 +1,7 @@
import Foundation
import Testing
-@testable import LifsaverCore
+@testable import LifsaverKit
// ===========================================================================
// Diagnostic report generation
@@ -18,31 +18,31 @@ private struct StubError: Error {}
DiagnosticsReporter(runner: runner, mountTable: mountTable)
}
- @Test func headerCarriesVersionAndPrivacyNote() async {
- let report = await reporter().generate()
- #expect(report.contains("# lifsaver diagnostic report"))
- #expect(report.contains("- version: \(lifsaverVersion)"))
- #expect(report.contains("Review it before sharing."))
+ @Test func metaCarriesVersionAndPrivacyNote() async {
+ let meta = await reporter().generate().meta
+ #expect(meta.version == lifsaverVersion)
+ #expect(meta.privacyNote.contains("Review it before sharing."))
}
@Test func scanTraceListsDetectedTargets() async {
- let report = await reporter().generate()
- #expect(report.contains("## Scan trace"))
- #expect(report.contains("target: disk4s1"))
+ let trace = await reporter().generate().scanTrace
+ #expect(trace.error == nil)
+ #expect(trace.targets.contains { $0.device == "disk4s1" })
}
- @Test func rawDiskutilSectionsIncludeListAndPerDiskInfo() async {
+ @Test func diskutilSectionsAreNativeStructuredJSON() async {
let report = await reporter().generate()
- #expect(report.contains("## diskutil list -plist"))
- #expect(report.contains("AllDisksAndPartitions"))
- #expect(report.contains("--- disk4 ---"))
- #expect(report.contains("RemovableMediaOrExternalDevice"))
+ // Parsed to native JSON, not an escaped XML blob: fields are queryable.
+ #expect(report.diskutilList["AllDisksAndPartitions"] != nil)
+ let disk4 = report.diskInfo.first { $0.device == "disk4" }
+ #expect(disk4?.info["RemovableMediaOrExternalDevice"]?.boolValue == true)
}
- @Test func mountTableEntriesAreRendered() async {
+ @Test func mountTableEntriesAreCaptured() async {
let table = FakeMountTable([MountEntry(device: "/dev/disk4s1", mountPoint: "/Volumes/CARD")])
let report = await reporter(mountTable: table).generate()
- #expect(report.contains("/dev/disk4s1 → /Volumes/CARD"))
+ #expect(report.mountTableError == nil)
+ #expect(report.mountTable.contains { $0.device == "/dev/disk4s1" && $0.mountPoint == "/Volumes/CARD" })
}
@Test func userNoteAndAppEventsAreEmbedded() async {
@@ -50,44 +50,63 @@ private struct StubError: Error {}
userNote: "Card stayed invisible after inserting.",
appEvents: ["2026-07-17T10:00:00Z mount attempt failed: boom"]
)
- #expect(report.contains("Card stayed invisible after inserting."))
- #expect(report.contains("## Recent app events"))
- #expect(report.contains("mount attempt failed: boom"))
+ #expect(report.userNote == "Card stayed invisible after inserting.")
+ #expect(report.appEvents == ["2026-07-17T10:00:00Z mount attempt failed: boom"])
}
@Test func missingNoteAndEventsDegradeGracefully() async {
let report = await reporter().generate(userNote: " \n")
- #expect(report.contains("(not provided)"))
- #expect(!report.contains("## Recent app events"))
+ #expect(report.userNote == nil)
+ #expect(report.appEvents.isEmpty)
+ #expect(report.liveLog.isEmpty)
+ }
+
+ @Test func liveLogIsEmbeddedVerbatim() async {
+ let lines = [
+ "2026-07-17T10:00:01Z Attempting diskutil mount...",
+ "--- escalated helper (root) ---",
+ ]
+ let report = await reporter().generate(liveLog: lines)
+ #expect(report.liveLog == lines)
}
@Test func failingCommandsNeverAbortTheReport() async {
let runner = FakeProcessRunner(throwing: StubError())
let report = await reporter(runner: runner, mountTable: FakeMountTable(throwing: StubError()))
.generate(userNote: "note")
- #expect(report.contains("scan failed:"))
- #expect(report.contains("unavailable:"))
- #expect(report.contains("note"))
+ #expect(report.scanTrace.error?.contains("scan failed:") == true)
+ #expect(report.mountTableError?.contains("unavailable:") == true)
+ #expect(report.userNote == "note")
}
- @Test func quietFsckProbeSaysNoneRunning() async {
+ @Test func quietFsckProbeYieldsNoProcesses() async {
let report = await reporter().generate()
- #expect(report.contains("(none running)"))
+ #expect(report.fsckProcesses.isEmpty)
+ }
+
+ /// The report exists to be machine-parsed: its JSON must round-trip.
+ @Test func jsonStringIsValidAndRoundTrips() async throws {
+ let report = await reporter().generate(userNote: "note", appEvents: ["event"], liveLog: ["log"])
+ let data = Data(report.jsonString().utf8)
+ let decoded = try JSONDecoder().decode(DiagnosticReport.self, from: data)
+ #expect(decoded.userNote == "note")
+ #expect(decoded.appEvents == ["event"])
+ #expect(decoded.meta.version == lifsaverVersion)
+ #expect(decoded.scanTrace.targets.contains { $0.device == "disk4s1" })
}
@Test func suggestedFilenameIsSortableAndSafe() {
let date = Date(timeIntervalSince1970: 0)
let name = DiagnosticsReporter.suggestedFilename(for: date)
#expect(name.hasPrefix("lifsaver-report-"))
- #expect(name.hasSuffix(".txt"))
+ #expect(name.hasSuffix(".json"))
#expect(!name.contains(" "))
#expect(!name.contains(":"))
}
}
// ===========================================================================
-// Support email (asserted via fragments — a full literal anywhere in the
-// repo would hand scrapers exactly what the runtime assembly hides)
+// Support email
// ===========================================================================
@Suite struct SupportEmailTests {
@@ -100,26 +119,12 @@ private struct StubError: Error {}
}
@Test func mailtoURLIsAddressedAndPrefilled() throws {
- let url = try #require(lifsaverReportMailtoURL(reportFilename: "lifsaver-report-x.txt"))
+ let url = try #require(lifsaverReportMailtoURL(reportFilename: "lifsaver-report-x.json"))
#expect(url.scheme == "mailto")
let absolute = url.absoluteString
#expect(absolute.contains("subject=lifsaver%20diagnostic%20report"))
- #expect(absolute.contains("lifsaver-report-x.txt"))
+ #expect(absolute.contains("lifsaver-report-x.json"))
let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))
#expect(components.path == lifsaverSupportEmail)
}
}
-
-// ===========================================================================
-// Install channel
-// ===========================================================================
-
-@Suite struct InstallChannelTests {
- @Test func caskroomPresenceMeansHomebrew() {
- #expect(lifsaverInstallChannel(directoryExists: { _ in true }) == "Homebrew cask")
- }
-
- @Test func noCaskroomMeansInstallerOrLocalBuild() {
- #expect(lifsaverInstallChannel(directoryExists: { _ in false }) == "installer package or local build")
- }
-}
diff --git a/Tests/LifsaverKitTests/EscalatedMountHelperTests.swift b/Tests/LifsaverKitTests/EscalatedMountHelperTests.swift
new file mode 100644
index 0000000..87d2b3d
--- /dev/null
+++ b/Tests/LifsaverKitTests/EscalatedMountHelperTests.swift
@@ -0,0 +1,154 @@
+import Foundation
+import Testing
+
+@testable import LifsaverKit
+
+// ===========================================================================
+// Escalated helper mount sequence
+// ===========================================================================
+
+/// Fake runner + mount table emulating a full scan-and-mount environment with
+/// one external Microsoft Basic Data partition per device id.
+private func scenario(
+ devices: [String], diskutilMountSucceeds: Bool = true,
+ diskutilListSucceeds: Bool = true, fsckActive: Bool = false
+) -> (runner: FakeProcessRunner, mountTable: FakeMountTable) {
+ let mountTable = FakeMountTable()
+ let listData = plistData([
+ "AllDisksAndPartitions": [
+ [
+ "DeviceIdentifier": "disk4",
+ "Partitions": devices.map {
+ ["DeviceIdentifier": $0, "Content": "Microsoft Basic Data"]
+ },
+ ]
+ ]
+ ])
+ let wholeDiskInfo = plistData(infoExternal)
+ let partitionInfo = plistData(["FilesystemType": "exfat"])
+ let runner = FakeProcessRunner { executable, arguments in
+ switch executable {
+ case "pgrep":
+ // pgrep exits 1 when no fsck is running; a match lists the raw device.
+ guard fsckActive else { return ProcessResult(status: 1) }
+ let listing = devices.map { "501 fsck_exfat /dev/r\($0)" }.joined(separator: "\n")
+ return ProcessResult(status: 0, stdout: Data(listing.utf8))
+ case "diskutil" where arguments.first == "list":
+ guard diskutilListSucceeds else { return ProcessResult(status: 1) }
+ return ProcessResult(status: 0, stdout: listData)
+ case "diskutil" where arguments.first == "info" && arguments.last == "disk4":
+ // Whole-disk externality query (hardware location lives only here).
+ return ProcessResult(status: 0, stdout: wholeDiskInfo)
+ case "diskutil" where arguments.first == "info":
+ return ProcessResult(status: 0, stdout: partitionInfo)
+ case "diskutil" where arguments.first == "mount":
+ if diskutilMountSucceeds {
+ mountTable.add(device: "/dev/\(arguments[1])", mountPoint: "/Volumes/CARD")
+ return ProcessResult(status: 0)
+ }
+ return ProcessResult(status: 1)
+ default:
+ return ProcessResult(status: 1)
+ }
+ }
+ return (runner, mountTable)
+}
+
+private struct HelperRun {
+ var status: Int32
+ var emitted: [String]
+ var console: CapturedConsole
+
+ /// The emitted stdout document decoded as the app would decode it.
+ func decodedReport() throws -> MountReport {
+ try JSONDecoder().decode(MountReport.self, from: Data(emitted.joined().utf8))
+ }
+}
+
+private func runHelper(
+ devices: [String],
+ diskutilMountSucceeds: Bool = true,
+ diskutilListSucceeds: Bool = true,
+ fsckActive: Bool = false
+) async -> HelperRun {
+ let (runner, mountTable) = scenario(
+ devices: devices, diskutilMountSucceeds: diskutilMountSucceeds,
+ diskutilListSucceeds: diskutilListSucceeds, fsckActive: fsckActive)
+ let console = CapturedConsole()
+ var emitted: [String] = []
+ let status = await EscalatedMountHelper.run(
+ runner: runner,
+ mountTable: mountTable,
+ fileOps: FakeFileOperations(),
+ console: console.console,
+ emit: { emitted.append($0) }
+ )
+ return HelperRun(status: status, emitted: emitted, console: console)
+}
+
+@Suite struct EscalatedMountHelperTests {
+ @Test func mountsEachTargetAndRoundTripsThroughSharedReportType() async throws {
+ // The app decodes this exact output as MountReport — assert the full
+ // document round-trips, not just substrings.
+ let run = await runHelper(devices: ["disk4s1"])
+ #expect(run.status == 0)
+ let report = try run.decodedReport()
+ #expect(report.targets == ["disk4s1"])
+ #expect(report.results == MountReport.Counts(ok: 1, fail: 0, skip: 0))
+ #expect(report.mounted == [MountReport.MountedVolume(device: "disk4s1", mountPoint: "/Volumes/CARD")])
+ }
+
+ @Test func emptyScanStillEmitsWellFormedReport() async throws {
+ // The app parses every run's output, so the empty case must still be
+ // a well-formed report rather than a human-readable sentence.
+ let run = await runHelper(devices: [])
+ #expect(run.status == 0)
+ let report = try run.decodedReport()
+ #expect(report.targets.isEmpty)
+ #expect(report.mounted.isEmpty)
+ #expect(report.results == MountReport.Counts(ok: 0, fail: 0, skip: 0))
+ }
+
+ @Test func exitsNonZeroWhenAMountFails() async throws {
+ let run = await runHelper(devices: ["disk4s1"], diskutilMountSucceeds: false)
+ #expect(run.status == 1)
+ let report = try run.decodedReport()
+ #expect(report.results == MountReport.Counts(ok: 0, fail: 1, skip: 0))
+ }
+
+ @Test func failedScanEmitsErrorReport() async throws {
+ // The invoking app discards this process's stderr, so the failure must
+ // arrive in-band as a well-formed report with `error` set — the user
+ // already paid for a password dialog and deserves the cause.
+ let run = await runHelper(devices: ["disk4s1"], diskutilListSucceeds: false)
+ #expect(run.status == 1)
+ #expect(run.console.errText.contains("CRITICAL"))
+ let report = try run.decodedReport()
+ #expect(report.error?.contains("root-side scan failed") == true)
+ #expect(report.targets.isEmpty)
+ #expect(report.results == MountReport.Counts(ok: 0, fail: 0, skip: 0))
+ }
+
+ @Test func reportCarriesTheLiveConsoleLog() async throws {
+ // The report's `log` is the only way the root-side play-by-play reaches
+ // diagnostic reports.
+ let run = await runHelper(devices: ["disk4s1"])
+ let report = try run.decodedReport()
+ #expect(report.log.contains { $0.contains("Target: /dev/disk4s1") })
+ #expect(report.log.contains { $0.contains("SUCCESS via diskutil") })
+ }
+
+ @Test func errorReportStillCarriesTheLogGatheredBeforeTheFailure() async throws {
+ let run = await runHelper(devices: ["disk4s1"], diskutilListSucceeds: false)
+ let report = try run.decodedReport()
+ #expect(report.log.contains { $0.contains("CRITICAL") })
+ }
+
+ @Test func activeFsckSkipsTargetRatherThanMounting() async throws {
+ // A skipped volume is not a failure — nothing was left in a bad state.
+ let run = await runHelper(devices: ["disk4s1"], fsckActive: true)
+ #expect(run.status == 0)
+ let report = try run.decodedReport()
+ #expect(report.results == MountReport.Counts(ok: 0, fail: 0, skip: 1))
+ }
+}
diff --git a/Tests/LifsaverCoreTests/Fixtures.swift b/Tests/LifsaverKitTests/Fixtures.swift
similarity index 99%
rename from Tests/LifsaverCoreTests/Fixtures.swift
rename to Tests/LifsaverKitTests/Fixtures.swift
index 65aa3f8..7c34d7d 100644
--- a/Tests/LifsaverCoreTests/Fixtures.swift
+++ b/Tests/LifsaverKitTests/Fixtures.swift
@@ -1,7 +1,7 @@
import Foundation
import os
-@testable import LifsaverCore
+@testable import LifsaverKit
// ---------------------------------------------------------------------------
// Fake process runner
diff --git a/Tests/LifsaverKitTests/KernelMountTableTests.swift b/Tests/LifsaverKitTests/KernelMountTableTests.swift
new file mode 100644
index 0000000..cadb69f
--- /dev/null
+++ b/Tests/LifsaverKitTests/KernelMountTableTests.swift
@@ -0,0 +1,16 @@
+import Foundation
+import Testing
+
+@testable import LifsaverKit
+
+// ===========================================================================
+// KernelMountTable (real getmntinfo — read-only, safe in CI)
+// ===========================================================================
+
+@Suite struct KernelMountTableTests {
+ @Test func liveTableContainsRootFilesystem() throws {
+ let entries = try KernelMountTable().entries()
+ #expect(entries.contains { $0.mountPoint == "/" })
+ #expect(entries.contains { $0.device.hasPrefix("/dev/disk") })
+ }
+}
diff --git a/Tests/LifsaverCoreTests/MounterTests.swift b/Tests/LifsaverKitTests/MounterTests.swift
similarity index 82%
rename from Tests/LifsaverCoreTests/MounterTests.swift
rename to Tests/LifsaverKitTests/MounterTests.swift
index 058801f..aa12f3d 100644
--- a/Tests/LifsaverCoreTests/MounterTests.swift
+++ b/Tests/LifsaverKitTests/MounterTests.swift
@@ -2,7 +2,7 @@ import Foundation
import Testing
import os
-@testable import LifsaverCore
+@testable import LifsaverKit
/// Records mount-point directory operations; optionally fails creation.
final class FakeFileOperations: FileOperating {
@@ -371,3 +371,74 @@ private final class MountScenario: Sendable {
#expect(!scenario.commandsSeen.contains { $0.contains("diskutil mount") })
}
}
+
+// ===========================================================================
+// execute — unreadable mount table
+// ===========================================================================
+
+@Suite struct UnreadableMountTableTests {
+ @Test func trustsMountExitStatusWhenTableUnreadable() async {
+ // An unreadable table is "unknown", not "unmounted": the mount
+ // binary's zero exit stands, so the pass doesn't report a spurious
+ // failure and trigger an unwarranted admin password prompt upstream.
+ let runner = FakeProcessRunner { executable, arguments in
+ switch executable {
+ case "pgrep":
+ return ProcessResult(status: 1)
+ case "diskutil" where arguments.first == "info":
+ return ProcessResult(status: 0, stdout: plistData(["FilesystemType": "exfat"]))
+ default:
+ return ProcessResult(status: 0)
+ }
+ }
+ let mounter = makeMounter(runner: runner, mountTable: FakeMountTable(throwing: POSIXError(.EIO)))
+ #expect(await mounter.execute("disk4s1") == .ok)
+ }
+}
+
+// ===========================================================================
+// mountAll (shared by the unprivileged pass and the root helper)
+// ===========================================================================
+
+/// Runner where `diskutil mount` succeeds per `mountSucceeds`, updating the
+/// mount table on success the way the real command does.
+private func mountingRunner(
+ table: FakeMountTable, mountSucceeds: @escaping @Sendable (String) -> Bool
+) -> FakeProcessRunner {
+ FakeProcessRunner { executable, arguments in
+ switch executable {
+ case "pgrep":
+ return ProcessResult(status: 1)
+ case "diskutil" where arguments.first == "info":
+ return ProcessResult(status: 0, stdout: plistData(["FilesystemType": "exfat"]))
+ case "diskutil" where arguments.first == "mount":
+ let devId = arguments[1]
+ guard mountSucceeds(devId) else { return ProcessResult(status: 1) }
+ table.add(device: "/dev/\(devId)", mountPoint: "/Volumes/CARD_\(devId)")
+ return ProcessResult(status: 0)
+ default:
+ return ProcessResult(status: 1)
+ }
+ }
+}
+
+@Suite struct MountAllTests {
+ @Test func talliesOutcomesAndCollectsMountedVolumes() async {
+ let table = FakeMountTable()
+ let mounter = makeMounter(runner: mountingRunner(table: table) { _ in true }, mountTable: table)
+ let result = await mounter.mountAll(["disk4s1", "disk4s2"])
+ #expect(result.counts == MountReport.Counts(ok: 2, fail: 0, skip: 0))
+ #expect(result.mounted.map(\.device) == ["disk4s1", "disk4s2"])
+ #expect(result.mounted.map(\.mountPoint) == ["/Volumes/CARD_disk4s1", "/Volumes/CARD_disk4s2"])
+ }
+
+ @Test func countsFailuresAndSkipsWithoutCollectingThem() async {
+ let table = FakeMountTable([MountEntry(device: "/dev/disk4s1", mountPoint: "/Volumes/CARD")])
+ let mounter = makeMounter(
+ runner: mountingRunner(table: table) { _ in false }, mountTable: table,
+ allowRawFallback: false)
+ let result = await mounter.mountAll(["disk4s1", "disk4s2"])
+ #expect(result.counts == MountReport.Counts(ok: 0, fail: 1, skip: 1))
+ #expect(result.mounted.isEmpty)
+ }
+}
diff --git a/Tests/LifsaverCoreTests/ProcessRunnerTests.swift b/Tests/LifsaverKitTests/ProcessRunnerTests.swift
similarity index 98%
rename from Tests/LifsaverCoreTests/ProcessRunnerTests.swift
rename to Tests/LifsaverKitTests/ProcessRunnerTests.swift
index dc4f7f0..34c8bfa 100644
--- a/Tests/LifsaverCoreTests/ProcessRunnerTests.swift
+++ b/Tests/LifsaverKitTests/ProcessRunnerTests.swift
@@ -1,7 +1,7 @@
import Foundation
import Testing
-@testable import LifsaverCore
+@testable import LifsaverKit
/// Real-subprocess tests for DefaultProcessRunner — everything else in the
/// suite fakes ProcessRunning, so the genuine launch/drain/timeout paths are
diff --git a/Tests/LifsaverKitTests/ScannerTests.swift b/Tests/LifsaverKitTests/ScannerTests.swift
new file mode 100644
index 0000000..8a8e12f
--- /dev/null
+++ b/Tests/LifsaverKitTests/ScannerTests.swift
@@ -0,0 +1,549 @@
+import Foundation
+import Testing
+import os
+
+@testable import LifsaverKit
+
+// ===========================================================================
+// activeMounts
+// ===========================================================================
+
+@Suite struct ActiveMountsTests {
+ @Test func collectsDevEntries() throws {
+ let table = FakeMountTable([
+ MountEntry(device: "/dev/disk1s1", mountPoint: "/"),
+ MountEntry(device: "/dev/disk3s1", mountPoint: "/Volumes/NO_NAME"),
+ ])
+ let result = try makeScanner(mountTable: table).activeMounts()
+ #expect(result == ["/dev/disk1s1", "/dev/disk3s1"])
+ }
+
+ @Test func ignoresVirtualFilesystems() throws {
+ let table = FakeMountTable([
+ MountEntry(device: "devfs", mountPoint: "/dev"),
+ MountEntry(device: "map auto_home", mountPoint: "/System/Volumes/Data/home"),
+ MountEntry(device: "/dev/disk1s1", mountPoint: "/"),
+ ])
+ let result = try makeScanner(mountTable: table).activeMounts()
+ #expect(result == ["/dev/disk1s1"])
+ }
+
+ @Test func throwsOnFailure() {
+ // A read failure must never read as "nothing mounted" — that would turn
+ // every mounted volume into a mount target.
+ let table = FakeMountTable(throwing: POSIXError(.EIO))
+ #expect(throws: (any Error).self) {
+ try makeScanner(mountTable: table).activeMounts()
+ }
+ }
+
+ @Test func returnsEmptySetOnEmptyTable() throws {
+ #expect(try makeScanner(mountTable: FakeMountTable()).activeMounts().isEmpty)
+ }
+}
+
+// ===========================================================================
+// isCurrentlyMounted
+// ===========================================================================
+
+@Suite struct IsCurrentlyMountedTests {
+ @Test func trueWhenPresent() {
+ let table = FakeMountTable([MountEntry(device: "/dev/disk4s1", mountPoint: "/Volumes/X")])
+ #expect(makeScanner(mountTable: table).isCurrentlyMounted("disk4s1") == true)
+ }
+
+ @Test func falseWhenAbsent() {
+ let table = FakeMountTable([MountEntry(device: "/dev/disk1s1", mountPoint: "/")])
+ #expect(makeScanner(mountTable: table).isCurrentlyMounted("disk4s1") == false)
+ }
+
+ @Test func nilWhenTableUnreadable() {
+ // "Unknown" is a distinct answer: callers must not conflate an
+ // unreadable table with "unmounted".
+ let captured = CapturedConsole()
+ let table = FakeMountTable(throwing: POSIXError(.EIO))
+ let scanner = makeScanner(mountTable: table, console: captured.console)
+ #expect(scanner.isCurrentlyMounted("disk4s1") == nil)
+ #expect(captured.errText.contains("WARNING"))
+ }
+
+ @Test func alwaysTakesFreshSnapshot() {
+ // Must never rely on a cached set — each call must hit the mount table.
+ let table = FakeMountTable()
+ let scanner = makeScanner(mountTable: table)
+ _ = scanner.isCurrentlyMounted("disk4s1")
+ _ = scanner.isCurrentlyMounted("disk4s1")
+ #expect(table.reads == 2)
+ }
+}
+
+// ===========================================================================
+// mountPoint(of:)
+// ===========================================================================
+
+@Suite struct MountPointTests {
+ @Test func extractsCorrectMountPoint() {
+ let table = FakeMountTable([MountEntry(device: "/dev/disk4s1", mountPoint: "/Volumes/CARD")])
+ #expect(makeScanner(mountTable: table).mountPoint(of: "disk4s1") == "/Volumes/CARD")
+ }
+
+ @Test func returnsEmptyStringWhenNotFound() {
+ let table = FakeMountTable([MountEntry(device: "/dev/disk1s1", mountPoint: "/")])
+ #expect(makeScanner(mountTable: table).mountPoint(of: "disk9s9").isEmpty)
+ }
+
+ @Test func returnsEmptyStringOnFailure() {
+ let table = FakeMountTable(throwing: POSIXError(.EIO))
+ #expect(makeScanner(mountTable: table).mountPoint(of: "disk4s1").isEmpty)
+ }
+
+ @Test func handlesMountPointWithSpaces() {
+ let table = FakeMountTable([MountEntry(device: "/dev/disk4s1", mountPoint: "/Volumes/My Card")])
+ #expect(makeScanner(mountTable: table).mountPoint(of: "disk4s1") == "/Volumes/My Card")
+ }
+}
+
+// ===========================================================================
+// isFsckActive
+// ===========================================================================
+
+@Suite struct IsFsckActiveTests {
+ private func scanner(pgrepOutput: String, status: Int32 = 0) -> DiskScanner {
+ makeScanner(runner: FakeProcessRunner(always: ProcessResult(status: status, stdout: Data(pgrepOutput.utf8))))
+ }
+
+ @Test func trueWhenFsckTargetsDevice() async {
+ let output = "812 /System/Library/Filesystems/exfat.fs/Contents/Resources/fsck_exfat -y /dev/rdisk4s1\n"
+ #expect(await scanner(pgrepOutput: output).isFsckActive("disk4s1"))
+ }
+
+ @Test func matchesNonRawDeviceNode() async {
+ #expect(await scanner(pgrepOutput: "812 fsck_msdos -y /dev/disk4s1\n").isFsckActive("disk4s1"))
+ }
+
+ @Test func falseWhenFsckTargetsOtherDevice() async {
+ #expect(!(await scanner(pgrepOutput: "812 fsck_exfat -y /dev/rdisk5s1\n").isFsckActive("disk4s1")))
+ }
+
+ @Test func noFalseMatchOnLongerIdentifier() async {
+ // disk4s1 must not match a check running on disk4s10.
+ #expect(!(await scanner(pgrepOutput: "812 fsck_exfat -y /dev/rdisk4s10\n").isFsckActive("disk4s1")))
+ }
+
+ @Test func noFalseMatchInsideLongerToken() async {
+ // disk4s1 must not match in the middle of an unrelated word like 'mydisk4s1'.
+ #expect(!(await scanner(pgrepOutput: "812 fsck_exfat -y /dev/mydisk4s1\n").isFsckActive("disk4s1")))
+ }
+
+ @Test func falseWhenNoFsckRunning() async {
+ #expect(!(await scanner(pgrepOutput: "", status: 1).isFsckActive("disk4s1")))
+ }
+
+ @Test func falseOnPgrepFailure() async {
+ let runner = FakeProcessRunner(
+ throwing: ProcessRunnerError.launchFailed(
+ command: "pgrep", underlying: CocoaError(.fileNoSuchFile)))
+ #expect(!(await makeScanner(runner: runner).isFsckActive("disk4s1")))
+ }
+}
+
+// ===========================================================================
+// diskData
+// ===========================================================================
+
+@Suite struct DiskDataTests {
+ @Test func returnsParsedPlist() async throws {
+ let runner = FakeProcessRunner(
+ always: ProcessResult(status: 0, stdout: plistData(diskutilPlistExternalExfat)))
+ let data = try await makeScanner(runner: runner).diskData()
+ #expect(data["AllDisksAndPartitions"] != nil)
+ }
+
+ @Test func throwsOnDiskutilFailure() async {
+ let scanner = makeScanner(runner: FakeProcessRunner(always: ProcessResult(status: 1)))
+ await #expect(throws: DiskUtilError.self) {
+ try await scanner.diskData()
+ }
+ }
+
+ @Test func throwsOnUnreadablePlist() async {
+ let scanner = makeScanner(
+ runner: FakeProcessRunner(always: ProcessResult(status: 0, stdout: Data("not a plist".utf8))))
+ await #expect(throws: DiskUtilError.self) {
+ try await scanner.diskData()
+ }
+ }
+}
+
+// ===========================================================================
+// partitionFSType
+// ===========================================================================
+
+@Suite struct PartitionFSTypeTests {
+ @Test func returnsFilesystemTypeLowercase() async {
+ let info: [String: Any] = ["FilesystemType": "ExFAT", "Content": "Microsoft Basic Data"]
+ let runner = FakeProcessRunner(always: ProcessResult(status: 0, stdout: plistData(info)))
+ #expect(await makeScanner(runner: runner).partitionFSType("disk4s1") == "exfat")
+ }
+
+ @Test(arguments: [("DOS_FAT_32", "msdos"), ("Windows_FAT_32", "msdos"), ("Windows_NTFS", "windows_ntfs")])
+ func fallsBackToContentWhenNoFilesystemType(content: String, expected: String) async {
+ // FAT partition types map onto the token the mount path understands (so
+ // mount_msdos is tried first); ambiguous types pass through.
+ let info: [String: Any] = ["Content": content]
+ let runner = FakeProcessRunner(always: ProcessResult(status: 0, stdout: plistData(info)))
+ #expect(await makeScanner(runner: runner).partitionFSType("disk4s1") == expected)
+ }
+
+ @Test func fallsBackToContentWhenFilesystemTypeEmpty() async {
+ let info: [String: Any] = ["FilesystemType": "", "Content": "DOS_FAT_32"]
+ let runner = FakeProcessRunner(always: ProcessResult(status: 0, stdout: plistData(info)))
+ #expect(await makeScanner(runner: runner).partitionFSType("disk4s1") == "msdos")
+ }
+
+ @Test func returnsEmptyStringOnFailure() async {
+ let runner = FakeProcessRunner(
+ throwing: ProcessRunnerError.launchFailed(
+ command: "diskutil", underlying: CocoaError(.fileNoSuchFile)))
+ #expect(await makeScanner(runner: runner).partitionFSType("disk4s1").isEmpty)
+ }
+
+ @Test func returnsEmptyStringWhenBothKeysMissing() async {
+ let runner = FakeProcessRunner(
+ always: ProcessResult(status: 0, stdout: plistData(["SomeOtherKey": "value"])))
+ #expect(await makeScanner(runner: runner).partitionFSType("disk4s1").isEmpty)
+ }
+}
+
+// ===========================================================================
+// isExternalHardware
+// ===========================================================================
+
+@Suite struct IsExternalHardwareTests {
+ @Test func externalBusIsExternal() async {
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ #expect(await makeScanner(runner: runner).isExternalHardware("disk4"))
+ }
+
+ @Test func internalFixedDiskIsNotExternal() async {
+ let runner = diskutilRunner(info: ["disk0": infoInternalFixed])
+ #expect(!(await makeScanner(runner: runner).isExternalHardware("disk0")))
+ }
+
+ @Test func removableMediaRescuesMisreportedInternal() async {
+ // USB bridges can misreport Internal; RemovableMediaOrExternalDevice
+ // is the second signal that still identifies the card reader.
+ let runner = diskutilRunner(info: ["disk4": infoMisreportingBridge])
+ #expect(await makeScanner(runner: runner).isExternalHardware("disk4"))
+ }
+
+ @Test func failsClosedOnMissingKeys() async {
+ let runner = diskutilRunner(info: ["disk4": ["Content": "GUID_partition_scheme"]])
+ #expect(!(await makeScanner(runner: runner).isExternalHardware("disk4")))
+ }
+
+ @Test func failsClosedOnDiskutilFailure() async {
+ let runner = FakeProcessRunner(always: ProcessResult(status: 1))
+ #expect(!(await makeScanner(runner: runner).isExternalHardware("disk4")))
+ }
+
+ @Test func failsClosedOnUnreadablePlist() async {
+ let runner = FakeProcessRunner(always: ProcessResult(status: 0, stdout: Data("not a plist".utf8)))
+ #expect(!(await makeScanner(runner: runner).isExternalHardware("disk4")))
+ }
+
+ @Test func retriesOnceAfterTransientQueryFailure() async {
+ // diskutil is most likely to be flaky exactly when diskarbitrationd is
+ // wedged on a stalled card; one transient failure must not hide it.
+ let external = plistData(infoExternal)
+ let attempts = OSAllocatedUnfairLock(initialState: 0)
+ let runner = FakeProcessRunner { _, _ in
+ let attempt = attempts.withLock { count -> Int in
+ count += 1
+ return count
+ }
+ return attempt == 1 ? ProcessResult(status: 1) : ProcessResult(status: 0, stdout: external)
+ }
+ #expect(await makeScanner(runner: runner).isExternalHardware("disk4"))
+ #expect(attempts.withLock { $0 } == 2)
+ }
+
+ @Test func confirmedInternalIsNotRetried() async {
+ // A successful query whose keys say "internal" is final.
+ let runner = diskutilRunner(info: ["disk0": infoInternalFixed])
+ #expect(!(await makeScanner(runner: runner).isExternalHardware("disk0")))
+ #expect(runner.calls.count == 1)
+ }
+}
+
+// ===========================================================================
+// filterTargetPartitions
+// ===========================================================================
+
+@Suite struct FilterTargetPartitionsTests {
+ @Test func picksUpUnmountedExternalExfat() async {
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let targets = await makeScanner(runner: runner)
+ .filterTargetPartitions(diskutilPlistExternalExfat, activeMounts: [])
+ #expect(targets == ["disk4s1"])
+ }
+
+ @Test func skipsInternalDisks() async {
+ // Boot Camp scenario: an unmounted NTFS/FAT partition on the internal
+ // fixed disk must never become a target.
+ let runner = diskutilRunner(info: ["disk0": infoInternalFixed])
+ let targets = await makeScanner(runner: runner)
+ .filterTargetPartitions(diskutilPlistInternal, activeMounts: [])
+ #expect(targets.isEmpty)
+ }
+
+ @Test func acceptsMisreportingBridge() async {
+ let runner = diskutilRunner(info: ["disk4": infoMisreportingBridge])
+ let targets = await makeScanner(runner: runner)
+ .filterTargetPartitions(diskutilPlistExternalExfat, activeMounts: [])
+ #expect(targets == ["disk4s1"])
+ }
+
+ @Test func failsClosedWhenExternalityUnknown() async {
+ // No info response for disk4 → treated as internal.
+ let targets = await makeScanner(runner: diskutilRunner())
+ .filterTargetPartitions(diskutilPlistExternalExfat, activeMounts: [])
+ #expect(targets.isEmpty)
+ }
+
+ @Test func skipsEFIPartition() async {
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let targets = await makeScanner(runner: runner).filterTargetPartitions(diskutilPlistEFI, activeMounts: [])
+ // disk4s1 = EFI (blocked), disk4s2 = Microsoft Basic Data (allowed)
+ #expect(!targets.contains("disk4s1"))
+ #expect(targets.contains("disk4s2"))
+ }
+
+ @Test func skipsAppleAPFSWithoutQueryingExternality() async {
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let targets = await makeScanner(runner: runner).filterTargetPartitions(diskutilPlistAPFS, activeMounts: [])
+ #expect(targets.isEmpty)
+ // Content filtering alone rules the disk out — no subprocess needed.
+ #expect(runner.calls.isEmpty)
+ }
+
+ @Test func skipsAlreadyMountedDevice() async {
+ let captured = CapturedConsole()
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let scanner = makeScanner(runner: runner, console: captured.console, verbose: true)
+ let targets = await scanner.filterTargetPartitions(diskutilPlistExternalExfat, activeMounts: ["/dev/disk4s1"])
+ #expect(targets.isEmpty)
+ #expect(captured.outText.contains("already mounted"))
+ }
+
+ @Test func verboseExplainsContentSkips() async {
+ // Diagnostic reports replay these lines; every rejection needs a why.
+ let captured = CapturedConsole()
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let scanner = makeScanner(runner: runner, console: captured.console, verbose: true)
+ _ = await scanner.filterTargetPartitions(diskutilPlistEFI, activeMounts: [])
+ #expect(captured.outText.contains("Skipping disk4s1 — system partition (EFI)."))
+
+ _ = await scanner.filterTargetPartitions(
+ [
+ "AllDisksAndPartitions": [
+ ["DeviceIdentifier": "disk6", "Partitions": [["DeviceIdentifier": "disk6s1"]]]
+ ]
+ ],
+ activeMounts: [])
+ #expect(captured.outText.contains("Skipping disk6s1 — Content (empty) is not camera-card-like."))
+ }
+
+ @Test func contentSkipsAreSilentByDefault() async {
+ let captured = CapturedConsole()
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let scanner = makeScanner(runner: runner, console: captured.console)
+ _ = await scanner.filterTargetPartitions(diskutilPlistEFI, activeMounts: [])
+ #expect(captured.outText.isEmpty)
+ }
+
+ @Test func alreadyMountedSkipIsSilentByDefault() async {
+ let captured = CapturedConsole()
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let scanner = makeScanner(runner: runner, console: captured.console)
+ let targets = await scanner.filterTargetPartitions(diskutilPlistExternalExfat, activeMounts: ["/dev/disk4s1"])
+ #expect(targets.isEmpty)
+ #expect(captured.outText.isEmpty)
+ }
+
+ @Test func multiDiskMultiPartition() async {
+ // disk4s1=EFI(skip), disk4s2=MBD(ok), disk4s3=DOS_FAT_32(ok),
+ // disk4s4=Windows_NTFS(ok — exFAT and NTFS share MBR type 0x07),
+ // disk5: personality-name variants; only allowlisted spellings match
+ let runner = diskutilRunner(info: ["disk4": infoExternal, "disk5": infoExternal])
+ let targets = await makeScanner(runner: runner).filterTargetPartitions(diskutilPlistMulti, activeMounts: [])
+ #expect(!targets.contains("disk4s1"))
+ #expect(targets.contains("disk4s2"))
+ #expect(targets.contains("disk4s3"))
+ #expect(targets.contains("disk4s4"))
+ #expect(targets.contains("disk5s1"))
+ #expect(targets.contains("disk5s2"))
+ #expect(!targets.contains("disk5s3"))
+ }
+
+ @Test func emptyDiskDataReturnsEmpty() async {
+ let targets = await makeScanner().filterTargetPartitions(["AllDisksAndPartitions": [Any]()], activeMounts: [])
+ #expect(targets.isEmpty)
+ }
+
+ @Test func partitionMissingDeviceIdentifierIsSkipped() async {
+ let data: [String: Any] = [
+ "AllDisksAndPartitions": [
+ [
+ "DeviceIdentifier": "disk4",
+ "Partitions": [["Content": "Microsoft Basic Data"]], // no DeviceIdentifier
+ ]
+ ]
+ ]
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: [])
+ #expect(targets.isEmpty)
+ }
+
+ @Test func diskMissingDeviceIdentifierIsSkipped() async {
+ // Candidate partitions but no whole-disk id to verify → fail closed.
+ let data: [String: Any] = [
+ "AllDisksAndPartitions": [
+ [
+ "Partitions": [["DeviceIdentifier": "disk4s1", "Content": "Microsoft Basic Data"]]
+ ]
+ ]
+ ]
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: [])
+ #expect(targets.isEmpty)
+ }
+
+ @Test(arguments: [
+ "Microsoft Basic Data",
+ "DOS_FAT_32",
+ "Windows_FAT_32",
+ "Windows_NTFS",
+ "exFAT",
+ "ExFAT",
+ ])
+ func allAllowlistedContentTypesAreAccepted(contentType: String) async {
+ let data: [String: Any] = [
+ "AllDisksAndPartitions": [
+ [
+ "DeviceIdentifier": "disk4",
+ "Partitions": [["DeviceIdentifier": "disk4s1", "Content": contentType]],
+ ]
+ ]
+ ]
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: [])
+ #expect(targets.contains("disk4s1"))
+ }
+
+ @Test(arguments: [
+ "Apple_APFS",
+ "Apple_HFS",
+ "Apple_Boot",
+ "Apple_Recovery",
+ "Apple_CoreStorage",
+ "EFI",
+ ])
+ func systemContentTypesAreRejected(contentType: String) async {
+ let data: [String: Any] = [
+ "AllDisksAndPartitions": [
+ [
+ "DeviceIdentifier": "disk4",
+ "Partitions": [["DeviceIdentifier": "disk4s1", "Content": contentType]],
+ ]
+ ]
+ ]
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: [])
+ #expect(targets.isEmpty)
+ }
+
+ @Test(arguments: ["Microsoft Basic Data Extra", "exFAT2", "NTFS"])
+ func allowlistMatchingIsExactNotSubstring(contentType: String) async {
+ // The most dangerous decision the app makes must not widen through a
+ // partial match against an unexpected Content value.
+ let data: [String: Any] = [
+ "AllDisksAndPartitions": [
+ [
+ "DeviceIdentifier": "disk4",
+ "Partitions": [["DeviceIdentifier": "disk4s1", "Content": contentType]],
+ ]
+ ]
+ ]
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: [])
+ #expect(targets.isEmpty)
+ }
+
+ @Test func unpartitionedSuperfloppyDiskIsATarget() async {
+ // Cards formatted without a partition map put the filesystem on the
+ // whole-disk node: no Partitions array, the Content sits on the disk.
+ let data: [String: Any] = [
+ "AllDisksAndPartitions": [
+ ["DeviceIdentifier": "disk4", "Content": "Windows_FAT_32"]
+ ]
+ ]
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: [])
+ #expect(targets == ["disk4"])
+ }
+
+ @Test func partitionSchemeWholeDiskIsNotATarget() async {
+ // A partitioned disk whose partitions were all filtered must not fall
+ // back to offering the whole-disk node.
+ let data: [String: Any] = [
+ "AllDisksAndPartitions": [
+ [
+ "DeviceIdentifier": "disk4",
+ "Content": "GUID_partition_scheme",
+ "Partitions": [["DeviceIdentifier": "disk4s1", "Content": "EFI"]],
+ ]
+ ]
+ ]
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: [])
+ #expect(targets.isEmpty)
+ }
+
+ @Test func mountedSuperfloppyDiskIsSkipped() async {
+ let data: [String: Any] = [
+ "AllDisksAndPartitions": [
+ ["DeviceIdentifier": "disk4", "Content": "Windows_FAT_32"]
+ ]
+ ]
+ let runner = diskutilRunner(info: ["disk4": infoExternal])
+ let targets = await makeScanner(runner: runner).filterTargetPartitions(data, activeMounts: ["/dev/disk4"])
+ #expect(targets.isEmpty)
+ }
+}
+
+// ===========================================================================
+// scanTargets (scan pipeline: diskutil plist + fresh mount table)
+// ===========================================================================
+
+@Suite struct ScanTargetsTests {
+ @Test func excludesDevicesAlreadyInMountTable() async throws {
+ let runner = diskutilRunner(
+ list: diskutilPlistMulti,
+ info: ["disk4": infoExternal, "disk5": infoExternal])
+ let table = FakeMountTable([MountEntry(device: "/dev/disk4s2", mountPoint: "/Volumes/CARD")])
+ let targets = try await makeScanner(runner: runner, mountTable: table).scanTargets()
+ #expect(!targets.contains("disk4s2"))
+ #expect(targets.contains("disk4s3"))
+ }
+
+ @Test func throwsWhenMountTableUnreadable() async {
+ // Guessing "nothing mounted" would offer every mounted volume as a
+ // target; a scan that cannot see the table must fail loudly.
+ let runner = diskutilRunner(list: diskutilPlistExternalExfat, info: ["disk4": infoExternal])
+ let table = FakeMountTable(throwing: POSIXError(.EIO))
+ await #expect(throws: DiskUtilError.self) {
+ try await makeScanner(runner: runner, mountTable: table).scanTargets()
+ }
+ }
+}
diff --git a/Tests/LifsaverCoreTests/StatusMenuModelTests.swift b/Tests/LifsaverKitTests/StatusMenuModelTests.swift
similarity index 77%
rename from Tests/LifsaverCoreTests/StatusMenuModelTests.swift
rename to Tests/LifsaverKitTests/StatusMenuModelTests.swift
index d899420..add87e5 100644
--- a/Tests/LifsaverCoreTests/StatusMenuModelTests.swift
+++ b/Tests/LifsaverKitTests/StatusMenuModelTests.swift
@@ -1,6 +1,6 @@
import Testing
-@testable import LifsaverCore
+@testable import LifsaverKit
// ===========================================================================
// Menu entries
@@ -11,19 +11,23 @@ import Testing
private func entries(
state: StatusMenuModel.ScanState,
newerVersion: String? = nil,
- isCheckingForUpdates: Bool = false,
showLaunchAtLogin: Bool = false,
- launchAtLoginEnabled: Bool = false
+ launchAtLoginEnabled: Bool = false,
+ automaticUpdatesEnabled: Bool = true
) -> [StatusMenuModel.Entry] {
StatusMenuModel.entries(
state: state,
newerVersion: newerVersion,
- isCheckingForUpdates: isCheckingForUpdates,
showLaunchAtLogin: showLaunchAtLogin,
- launchAtLoginEnabled: launchAtLoginEnabled
+ launchAtLoginEnabled: launchAtLoginEnabled,
+ automaticUpdatesEnabled: automaticUpdatesEnabled
)
}
+ /// The "Settings" submenu with the test defaults (unbundled, auto-check on).
+ private let moreOptions = StatusMenuModel.Entry.moreOptions(
+ showStartAtLogin: false, startAtLoginEnabled: false, automaticUpdatesEnabled: true)
+
@Test func scanningShowsPlaceholder() {
#expect(
entries(state: .scanning) == [
@@ -31,17 +35,19 @@ import Testing
.separator,
.saveReport(title: "Send Diagnostic Report"),
.checkForUpdates(title: "Check for Updates"),
+ moreOptions,
.quit(title: "Quit"),
])
}
- @Test func failureShowsTerminalHint() {
+ @Test func failureShowsDisabledNotice() {
#expect(
entries(state: .failed) == [
- .disabled("Scan failed — try `lifsaver --verbose` in Terminal"),
+ .disabled("Scan failed"),
.separator,
.saveReport(title: "Send Diagnostic Report"),
.checkForUpdates(title: "Check for Updates"),
+ moreOptions,
.quit(title: "Quit"),
])
}
@@ -53,6 +59,7 @@ import Testing
.separator,
.saveReport(title: "Send Diagnostic Report"),
.checkForUpdates(title: "Check for Updates"),
+ moreOptions,
.quit(title: "Quit"),
])
}
@@ -65,6 +72,7 @@ import Testing
.separator,
.saveReport(title: "Send Diagnostic Report"),
.checkForUpdates(title: "Check for Updates"),
+ moreOptions,
.quit(title: "Quit"),
])
}
@@ -80,6 +88,7 @@ import Testing
.separator,
.saveReport(title: "Send Diagnostic Report"),
.checkForUpdates(title: "Check for Updates"),
+ moreOptions,
.quit(title: "Quit"),
])
}
@@ -91,24 +100,22 @@ import Testing
#expect(result.last == .quit(title: "Quit"))
}
- @Test func checkingShowsProgressPlaceholder() {
- let result = entries(state: .scanning, isCheckingForUpdates: true)
- #expect(result.contains(.disabled("Checking for Updates…")))
- #expect(!result.contains(.checkForUpdates(title: "Check for Updates")))
- }
+ @Test func moreOptionsHidesStartAtLoginWhenUnbundled() {
+ #expect(
+ entries(state: .scanning).contains(
+ .moreOptions(showStartAtLogin: false, startAtLoginEnabled: false, automaticUpdatesEnabled: true)))
- @Test func knownUpdateWinsOverCheckingState() {
- // A newer version already in hand shows the update item, not progress.
- let result = entries(state: .scanning, newerVersion: "2.1.0", isCheckingForUpdates: true)
- #expect(result.contains(.updateAvailable(title: "Update to version 2.1.0")))
- #expect(!result.contains(.disabled("Checking for Updates…")))
+ let bundled = entries(state: .scanning, showLaunchAtLogin: true, launchAtLoginEnabled: true)
+ #expect(
+ bundled.contains(
+ .moreOptions(showStartAtLogin: true, startAtLoginEnabled: true, automaticUpdatesEnabled: true)))
}
- @Test func launchAtLoginShownOnlyWhenBundled() {
- #expect(!entries(state: .scanning).contains(.launchAtLogin(enabled: false)))
-
- let bundled = entries(state: .scanning, showLaunchAtLogin: true, launchAtLoginEnabled: true)
- #expect(bundled.contains(.launchAtLogin(enabled: true)))
+ @Test func moreOptionsReflectsAutomaticUpdatesOptOut() {
+ let optedOut = entries(state: .scanning, automaticUpdatesEnabled: false)
+ #expect(
+ optedOut.contains(
+ .moreOptions(showStartAtLogin: false, startAtLoginEnabled: false, automaticUpdatesEnabled: false)))
}
/// Reports are most needed exactly when scans fail, so the item must
@@ -144,12 +151,6 @@ import Testing
== "mount attempt finished: 1 mounted, 2 failed, 3 skipped")
}
- @Test func missingCLIIsExplicit() {
- #expect(
- StatusMenuModel.mountEventLine(for: .cliNotFound)
- == "mount attempt failed: bundled CLI missing")
- }
-
@Test func unprivilegedPassRecordsWhatRootWasNeededFor() {
#expect(
StatusMenuModel.unprivilegedMountEventLine(for: .init(ok: 1, fail: 2, skip: 3))
@@ -163,7 +164,7 @@ import Testing
@Suite struct CombinedOutcomeTests {
private func combined(
- _ unprivileged: CLIReport.Counts, _ escalated: EscalatedMountOutcome?
+ _ unprivileged: MountReport.Counts, _ escalated: EscalatedMountOutcome?
) -> EscalatedMountOutcome {
StatusMenuModel.combinedOutcome(unprivileged: unprivileged, escalated: escalated)
}
@@ -189,14 +190,15 @@ import Testing
== .report(.init(ok: 2, fail: 1, skip: 1)))
}
- @Test func cancellationAloneStaysSilent() {
- #expect(combined(.init(fail: 1), .cancelled) == .cancelled)
+ @Test func cancellationAloneReportsTheDeclinedVolumesAsFailed() {
+ // Declining the password leaves the volume unmounted — a failure.
+ #expect(combined(.init(fail: 1), .cancelled) == .report(.init(fail: 1)))
}
- @Test func cancellingTheRestKeepsWhatAlreadyMounted() {
- // Declining the dialog is not a failure of the volume that mounted
- // before it appeared.
- #expect(combined(.init(ok: 1, fail: 1), .cancelled) == .report(.init(ok: 1)))
+ @Test func cancellingTheRestFailsThemAndKeepsWhatAlreadyMounted() {
+ // What mounted before the prompt still counts; what the user declined
+ // to authorize is reported as failed, not silently dropped.
+ #expect(combined(.init(ok: 1, fail: 1), .cancelled) == .report(.init(ok: 1, fail: 1)))
}
@Test func errorAlonePassesThrough() {
@@ -209,10 +211,6 @@ import Testing
combined(.init(ok: 1, fail: 2), .error("osascript exploded"))
== .report(.init(ok: 1, fail: 2)))
}
-
- @Test func missingCLIAfterPartialSuccessKeepsBothHalves() {
- #expect(combined(.init(ok: 1, fail: 1), .cliNotFound) == .report(.init(ok: 1, fail: 1)))
- }
}
// ===========================================================================
@@ -242,14 +240,20 @@ import Testing
#expect(body(for: .cancelled) == nil)
}
- @Test func missingCLIAsksForReinstall() {
- #expect(body(for: .cliNotFound) == "Bundled CLI is missing — please reinstall the app.")
+ @Test func singleVolumeFailureIsPlain() {
+ #expect(body(for: .report(.init(ok: 0, fail: 1, skip: 0))) == "Mount failed")
}
- @Test func anyFailureWinsOverSuccesses() {
+ @Test func multiVolumeFailureSummarisesBothHalves() {
#expect(
body(for: .report(.init(ok: 2, fail: 1, skip: 0)))
- == "Mount failed for 1 volume(s) — try `lifsaver --verbose` in Terminal.")
+ == "Mount failed (2 mounted, 1 failed)")
+ }
+
+ @Test func allVolumesFailingStillSummarisesWhenSeveral() {
+ #expect(
+ body(for: .report(.init(ok: 0, fail: 2, skip: 0)))
+ == "Mount failed (0 mounted, 2 failed)")
}
@Test func singleSuccessUsesSingularNoun() {
@@ -266,8 +270,8 @@ import Testing
== "Nothing mounted — volumes were skipped (already mounted or being checked).")
}
- @Test func errorSuggestsVerboseRun() {
- #expect(body(for: .error("osascript exploded")) == "Mount failed — try `lifsaver --verbose` in Terminal.")
+ @Test func errorReportsPlainFailure() {
+ #expect(body(for: .error("osascript exploded")) == "Mount failed")
}
}
@@ -327,23 +331,3 @@ import Testing
== "3 stalled volumes detected")
}
}
-
-// ===========================================================================
-// Scan generation
-// ===========================================================================
-
-@Suite struct ScanGenerationTests {
- @Test func latestTokenIsCurrent() {
- var generation = ScanGeneration()
- let token = generation.begin()
- #expect(generation.isCurrent(token))
- }
-
- @Test func newScanInvalidatesEarlierToken() {
- var generation = ScanGeneration()
- let stale = generation.begin()
- let fresh = generation.begin()
- #expect(!generation.isCurrent(stale))
- #expect(generation.isCurrent(fresh))
- }
-}
diff --git a/Tests/LifsaverCoreTests/UpdateCheckerTests.swift b/Tests/LifsaverKitTests/UpdateCheckerTests.swift
similarity index 54%
rename from Tests/LifsaverCoreTests/UpdateCheckerTests.swift
rename to Tests/LifsaverKitTests/UpdateCheckerTests.swift
index e543716..eb3819e 100644
--- a/Tests/LifsaverCoreTests/UpdateCheckerTests.swift
+++ b/Tests/LifsaverKitTests/UpdateCheckerTests.swift
@@ -2,7 +2,7 @@ import Foundation
import Testing
import os
-@testable import LifsaverCore
+@testable import LifsaverKit
// ---------------------------------------------------------------------------
// Helpers
@@ -28,37 +28,19 @@ final class FakeReleaseFetcher: LatestReleaseFetching {
}
}
-final class EmittedLines {
- private let state = OSAllocatedUnfairLock(initialState: [String]())
-
- var lines: [String] { state.withLock { $0 } }
- var emit: @Sendable (String) -> Void {
- { [state] line in state.withLock { $0.append(line) } }
- }
- var text: String { lines.joined(separator: "\n") }
-}
-
private func makeChecker(
currentVersion: String = "1.0.0",
fetcher: FakeReleaseFetcher = FakeReleaseFetcher(tag: nil),
- environment: [String: String] = [:],
- interactive: Bool = true,
cacheDirectory: URL,
- emitted: EmittedLines = EmittedLines(),
- checkInterval: TimeInterval = 24 * 60 * 60,
- upgradeCommand: String = "brew upgrade --cask lifsaver"
+ checkInterval: TimeInterval = 24 * 60 * 60
) -> UpdateChecker {
UpdateChecker(
package: "lifsaver",
repo: "lucuma13/lifsaver",
currentVersion: currentVersion,
- upgradeCommand: upgradeCommand,
checkInterval: checkInterval,
cacheDirectory: cacheDirectory,
- fetcher: fetcher,
- environment: environment,
- stderrIsInteractive: { interactive },
- emit: emitted.emit
+ fetcher: fetcher
)
}
@@ -69,10 +51,9 @@ private func temporaryDirectory() -> URL {
return url
}
-/// start() + notify() with a generous join window so the fetch always lands.
+/// start() and wait for any background fetch to land.
private func check(_ checker: UpdateChecker) async {
- checker.start()
- await checker.notify(timeout: 5)
+ await checker.start()?.value
}
// ===========================================================================
@@ -125,79 +106,10 @@ private func check(_ checker: UpdateChecker) async {
}
// ===========================================================================
-// Upgrade-command channel detection
-// ===========================================================================
-
-@Suite struct UpgradeCommandTests {
- @Test(arguments: ["/opt/homebrew/Caskroom/lifsaver", "/usr/local/Caskroom/lifsaver"])
- func brewCommandWhenCaskroomExists(caskroom: String) {
- let command = lifsaverUpgradeCommand(directoryExists: { $0 == caskroom })
- #expect(command == "brew upgrade --cask lifsaver")
- }
-
- @Test func directDownloadLinkWithoutCaskroom() {
- let command = lifsaverUpgradeCommand(directoryExists: { _ in false })
- #expect(
- command
- == "open https://github.com/lucuma13/lifsaver/releases/download/v{latest}/lifsaver_installer_macos.pkg"
- )
- }
-
- @Test func directoryOnDiskIsDetected() {
- let directory = temporaryDirectory()
- defer { try? FileManager.default.removeItem(at: directory) }
- #expect(directoryExistsOnDisk(directory.path))
- }
-
- @Test func missingPathIsNotADirectory() {
- let missing = temporaryDirectory().appendingPathComponent("nope")
- #expect(!directoryExistsOnDisk(missing.path))
- }
-
- @Test func plainFileIsNotMistakenForACaskInstall() throws {
- // A Caskroom path occupied by a file — not a directory — must not be
- // read as a Homebrew install, or we would suggest `brew upgrade` to
- // someone who installed from the .pkg.
- let directory = temporaryDirectory()
- defer { try? FileManager.default.removeItem(at: directory) }
- let file = directory.appendingPathComponent("lifsaver")
- try Data("not a directory".utf8).write(to: file)
- #expect(!directoryExistsOnDisk(file.path))
- }
-
- @Test func notifySubstitutesLatestVersionIntoCommand() async {
- let emitted = EmittedLines()
- let checker = makeChecker(
- fetcher: FakeReleaseFetcher(tag: "v2.1.0"),
- cacheDirectory: temporaryDirectory(),
- emitted: emitted,
- upgradeCommand: lifsaverUpgradeCommand(directoryExists: { _ in false })
- )
- await check(checker)
- #expect(
- emitted.text.contains(
- "open https://github.com/lucuma13/lifsaver/releases/download/v2.1.0/lifsaver_installer_macos.pkg"
- ))
- }
-}
-
-// ===========================================================================
-// Enablement / opt-outs
+// Enablement
// ===========================================================================
@Suite struct UpdateCheckerEnablementTests {
- @Test(arguments: ["LIFSAVER_NO_UPDATE_CHECK", "NO_UPDATE_CHECK", "CI"])
- func disabledByEnvVar(variable: String) async {
- let fetcher = FakeReleaseFetcher(tag: "v9.9.9")
- let emitted = EmittedLines()
- let checker = makeChecker(
- fetcher: fetcher, environment: [variable: "1"],
- cacheDirectory: temporaryDirectory(), emitted: emitted)
- await check(checker)
- #expect(fetcher.fetchCount == 0)
- #expect(emitted.lines.isEmpty)
- }
-
@Test func disabledForUnparsableVersion() async {
let fetcher = FakeReleaseFetcher(tag: "v9.9.9")
let checker = makeChecker(
@@ -205,14 +117,6 @@ private func check(_ checker: UpdateChecker) async {
await check(checker)
#expect(fetcher.fetchCount == 0)
}
-
- @Test func disabledWhenStderrNotATTY() async {
- let fetcher = FakeReleaseFetcher(tag: "v9.9.9")
- let checker = makeChecker(
- fetcher: fetcher, interactive: false, cacheDirectory: temporaryDirectory())
- await check(checker)
- #expect(fetcher.fetchCount == 0)
- }
}
// ===========================================================================
@@ -229,11 +133,10 @@ private func check(_ checker: UpdateChecker) async {
.write(to: directory.appendingPathComponent("update-check.json"))
let fetcher = FakeReleaseFetcher(tag: "v1.0.0")
- let emitted = EmittedLines()
- let checker = makeChecker(fetcher: fetcher, cacheDirectory: directory, emitted: emitted)
+ let checker = makeChecker(fetcher: fetcher, cacheDirectory: directory)
await check(checker)
#expect(fetcher.fetchCount == 0)
- #expect(emitted.text.contains("Update available"))
+ #expect(checker.knownNewerVersion() == "9.9.9")
}
@Test func staleCacheTriggersFetchAndRewrite() async throws {
@@ -245,11 +148,10 @@ private func check(_ checker: UpdateChecker) async {
try JSONSerialization.data(withJSONObject: stale).write(to: cacheFile)
let fetcher = FakeReleaseFetcher(tag: "v9.9.9")
- let emitted = EmittedLines()
- let checker = makeChecker(fetcher: fetcher, cacheDirectory: directory, emitted: emitted)
+ let checker = makeChecker(fetcher: fetcher, cacheDirectory: directory)
await check(checker)
#expect(fetcher.fetchCount == 1)
- #expect(emitted.text.contains("Update available"))
+ #expect(checker.knownNewerVersion() == "9.9.9")
let rewritten = try JSONSerialization.jsonObject(with: Data(contentsOf: cacheFile))
let object = try #require(rewritten as? [String: Any])
@@ -268,100 +170,64 @@ private func check(_ checker: UpdateChecker) async {
#expect(permissions == 0o600)
}
+ @Test func futureTimestampReadsAsStale() async throws {
+ // Clock skew (fast clock at write, NTP correction after) must not pin
+ // the cache "fresh" until the wall clock catches up.
+ let directory = temporaryDirectory()
+ let future: [String: Any] = [
+ "latest": "0.0.1", "checked_at": Date().timeIntervalSince1970 + 3 * 24 * 60 * 60,
+ ]
+ try JSONSerialization.data(withJSONObject: future)
+ .write(to: directory.appendingPathComponent("update-check.json"))
+
+ let fetcher = FakeReleaseFetcher(tag: "v9.9.9")
+ let checker = makeChecker(fetcher: fetcher, cacheDirectory: directory)
+ await check(checker)
+ #expect(fetcher.fetchCount == 1)
+ #expect(checker.knownNewerVersion() == "9.9.9")
+ }
+
@Test func corruptCacheIsIgnored() async throws {
let directory = temporaryDirectory()
try Data("not json at all {".utf8)
.write(to: directory.appendingPathComponent("update-check.json"))
let fetcher = FakeReleaseFetcher(tag: "v9.9.9")
- let emitted = EmittedLines()
- let checker = makeChecker(fetcher: fetcher, cacheDirectory: directory, emitted: emitted)
+ let checker = makeChecker(fetcher: fetcher, cacheDirectory: directory)
await check(checker)
#expect(fetcher.fetchCount == 1)
- #expect(emitted.text.contains("Update available"))
+ #expect(checker.knownNewerVersion() == "9.9.9")
}
@Test func writeCacheFailureIsSilent() async {
// A cache directory that cannot be created must not break the check.
let impossible = URL(fileURLWithPath: "/dev/null/nope")
- let emitted = EmittedLines()
let checker = makeChecker(
- fetcher: FakeReleaseFetcher(tag: "v9.9.9"), cacheDirectory: impossible,
- emitted: emitted)
+ fetcher: FakeReleaseFetcher(tag: "v9.9.9"), cacheDirectory: impossible)
await check(checker)
- #expect(emitted.text.contains("Update available"))
+ #expect(checker.knownNewerVersion() == "9.9.9")
}
}
// ===========================================================================
-// Notification behaviour
+// Check behaviour
// ===========================================================================
-@Suite struct UpdateCheckerNotifyTests {
- @Test func fetchQueriesAndNotifies() async {
+@Suite struct UpdateCheckerCheckTests {
+ @Test func fetchQueriesAndRecordsNewerVersion() async {
let fetcher = FakeReleaseFetcher(tag: "v9.9.9")
- let emitted = EmittedLines()
- let checker = makeChecker(
- fetcher: fetcher, cacheDirectory: temporaryDirectory(), emitted: emitted)
+ let checker = makeChecker(fetcher: fetcher, cacheDirectory: temporaryDirectory())
await check(checker)
#expect(fetcher.fetchCount == 1)
#expect(fetcher.lastUserAgent == "lifsaver/1.0.0 (update-check)")
- #expect(emitted.text.contains("Update available! Run: "))
- #expect(emitted.text.contains("brew upgrade --cask lifsaver"))
- }
-
- @Test func noHintWhenUpToDate() async {
- let emitted = EmittedLines()
- let checker = makeChecker(
- fetcher: FakeReleaseFetcher(tag: "v1.0.0"),
- cacheDirectory: temporaryDirectory(), emitted: emitted)
- await check(checker)
- #expect(emitted.lines.isEmpty)
+ #expect(checker.knownNewerVersion() == "9.9.9")
}
@Test func networkFailureIsSilent() async {
- let emitted = EmittedLines()
- let checker = makeChecker(
- fetcher: FakeReleaseFetcher(tag: nil),
- cacheDirectory: temporaryDirectory(), emitted: emitted)
- await check(checker)
- #expect(emitted.lines.isEmpty)
- }
-
- @Test func notifyWithoutStartStaysSilent() async {
- // notify() must not hang or hint when start() was never called.
- let emitted = EmittedLines()
let checker = makeChecker(
- fetcher: FakeReleaseFetcher(tag: "v9.9.9"),
- cacheDirectory: temporaryDirectory(), emitted: emitted)
- await checker.notify(timeout: 0.05)
- #expect(emitted.lines.isEmpty)
- }
-
- @Test func hintIsColouredWhenStderrSupportsIt() async {
- let emitted = EmittedLines()
- let checker = makeChecker(
- fetcher: FakeReleaseFetcher(tag: "v9.9.9"),
- cacheDirectory: temporaryDirectory(), emitted: emitted)
- await check(checker)
- #expect(emitted.text.contains("\u{1B}[38;5;208m"))
- #expect(emitted.text.contains("\u{1B}[1m"))
- }
-
- @Test(
- arguments: [
- (["NO_COLOR": "1"], false),
- (["FORCE_COLOR": "1"], true),
- (["NO_COLOR": "1", "FORCE_COLOR": "1"], false), // NO_COLOR wins
- ([:], true), // interactive default
- ] as [([String: String], Bool)])
- func colourFollowsNoColorAndForceColor(environment: [String: String], coloured: Bool) async {
- let emitted = EmittedLines()
- let checker = makeChecker(
- fetcher: FakeReleaseFetcher(tag: "v9.9.9"), environment: environment,
- cacheDirectory: temporaryDirectory(), emitted: emitted)
+ fetcher: FakeReleaseFetcher(tag: nil), cacheDirectory: temporaryDirectory())
await check(checker)
- #expect(emitted.text.contains("\u{1B}[") == coloured)
+ #expect(checker.knownNewerVersion() == nil)
}
@Test func knownNewerVersionForMenuSurface() async {
@@ -376,14 +242,33 @@ private func check(_ checker: UpdateChecker) async {
#expect(upToDate.knownNewerVersion() == nil)
}
- @Test func checkNowFetchesEvenWhenOptedOut() async {
- // The manual GUI check ignores the CI/opt-out gates that silence the
- // passive CLI hint — the user explicitly asked.
+ @Test func checkNowFetchesEvenForDevBuilds() async {
+ // The manual GUI check ignores the version gate that silences the
+ // passive launch check — the user explicitly asked.
let fetcher = FakeReleaseFetcher(tag: "v9.9.9")
let checker = makeChecker(
- fetcher: fetcher, environment: ["CI": "1"], cacheDirectory: temporaryDirectory())
- await checker.checkNow()
+ currentVersion: "unknown", fetcher: fetcher, cacheDirectory: temporaryDirectory())
+ _ = await checker.checkNow()
#expect(fetcher.fetchCount == 1)
- #expect(checker.knownNewerVersion() == "9.9.9")
+ }
+
+ @Test func checkNowReportsUpdateAvailable() async {
+ let checker = makeChecker(
+ fetcher: FakeReleaseFetcher(tag: "v9.9.9"), cacheDirectory: temporaryDirectory())
+ #expect(await checker.checkNow() == .updateAvailable("9.9.9"))
+ }
+
+ @Test func checkNowReportsUpToDate() async {
+ let checker = makeChecker(
+ fetcher: FakeReleaseFetcher(tag: "v1.0.0"), cacheDirectory: temporaryDirectory())
+ #expect(await checker.checkNow() == .upToDate)
+ }
+
+ @Test func checkNowReportsFailureWhenFetchFails() async {
+ // A nil fetch stands for every failure: offline, unreachable, or no
+ // release published yet.
+ let checker = makeChecker(
+ fetcher: FakeReleaseFetcher(tag: nil), cacheDirectory: temporaryDirectory())
+ #expect(await checker.checkNow() == .failed)
}
}
diff --git a/Tests/LifsaverKitTests/VersionTests.swift b/Tests/LifsaverKitTests/VersionTests.swift
new file mode 100644
index 0000000..998b8aa
--- /dev/null
+++ b/Tests/LifsaverKitTests/VersionTests.swift
@@ -0,0 +1,15 @@
+import Foundation
+import Testing
+
+@testable import LifsaverKit
+
+// ===========================================================================
+// Version
+// ===========================================================================
+
+@Suite struct VersionTests {
+ @Test func versionIsANonEmptySemanticVersion() {
+ #expect(!lifsaverVersion.isEmpty)
+ #expect(SemanticVersion(lifsaverVersion) != nil)
+ }
+}
diff --git a/docs/images/demo.gif b/docs/images/demo.gif
deleted file mode 100644
index d6391da..0000000
Binary files a/docs/images/demo.gif and /dev/null differ
diff --git a/docs/images/lifsaver_demo_animation.gif b/docs/images/lifsaver_demo_animation.gif
new file mode 100644
index 0000000..35f5a80
Binary files /dev/null and b/docs/images/lifsaver_demo_animation.gif differ
diff --git a/docs/images/lifsaver_logo_name.svg b/docs/images/lifsaver_logo_name.svg
index 899b526..9b78ffc 100644
--- a/docs/images/lifsaver_logo_name.svg
+++ b/docs/images/lifsaver_logo_name.svg
@@ -13,14 +13,10 @@
-
-
-
-
diff --git a/docs/images/lifsaver_social_preview.png b/docs/images/lifsaver_social_preview.png
new file mode 100644
index 0000000..9cc05fa
Binary files /dev/null and b/docs/images/lifsaver_social_preview.png differ
diff --git a/scripts/release/app-resources/AppIcon.icns b/scripts/release/app-resources/AppIcon.icns
index 3ced11c..1f56e37 100644
Binary files a/scripts/release/app-resources/AppIcon.icns and b/scripts/release/app-resources/AppIcon.icns differ
diff --git a/scripts/release/build.sh b/scripts/release/build.sh
index d5d59f4..16ebc13 100755
--- a/scripts/release/build.sh
+++ b/scripts/release/build.sh
@@ -1,5 +1,5 @@
#!/bin/bash
-# Build universal (arm64 + x86_64) release binaries for the app and CLI.
+# Build the universal (arm64 + x86_64) release binary.
set -euo pipefail
@@ -11,13 +11,11 @@ swift build -c release --arch x86_64
OUT="$ROOT/.build/universal-release"
mkdir -p "$OUT"
-for product in lifsaver LifsaverApp; do
- lipo -create \
- ".build/arm64-apple-macosx/release/$product" \
- ".build/x86_64-apple-macosx/release/$product" \
- -output "$OUT/$product"
- printf '%s: ' "$product"
- lipo -archs "$OUT/$product"
-done
+lipo -create \
+ ".build/arm64-apple-macosx/release/Lifsaver" \
+ ".build/x86_64-apple-macosx/release/Lifsaver" \
+ -output "$OUT/Lifsaver"
+printf 'Lifsaver: '
+lipo -archs "$OUT/Lifsaver"
echo "Products: $OUT"
diff --git a/scripts/release/bundle_app.sh b/scripts/release/bundle_app.sh
index 088f3bd..34325cf 100755
--- a/scripts/release/bundle_app.sh
+++ b/scripts/release/bundle_app.sh
@@ -1,7 +1,6 @@
#!/bin/bash
# Assemble Lifsaver.app from the release build products:
-# Contents/MacOS/Lifsaver — menu bar app
-# Contents/Helpers/lifsaver — CLI (also symlinked onto PATH by the pkg)
+# Contents/MacOS/Lifsaver
# Ends with an ad-hoc re-sign: Apple Silicon refuses to execute unsigned
# Mach-O code, and mutating the bundle invalidates any prior seal.
set -euo pipefail
@@ -12,18 +11,17 @@ cd "$ROOT"
VERSION="$("$ROOT/scripts/version.sh")"
BIN_PATH="$ROOT/.build/universal-release"
-[ -x "$BIN_PATH/LifsaverApp" ] || {
+[ -x "$BIN_PATH/Lifsaver" ] || {
echo "ERROR: run scripts/release/build.sh first" >&2
exit 1
}
APP="$ROOT/dist/Lifsaver.app"
rm -rf "$APP"
-mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Helpers" "$APP/Contents/Resources"
+mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
-cp "$BIN_PATH/LifsaverApp" "$APP/Contents/MacOS/Lifsaver"
-cp "$BIN_PATH/lifsaver" "$APP/Contents/Helpers/lifsaver"
-chmod 755 "$APP/Contents/MacOS/Lifsaver" "$APP/Contents/Helpers/lifsaver"
+cp "$BIN_PATH/Lifsaver" "$APP/Contents/MacOS/Lifsaver"
+chmod 755 "$APP/Contents/MacOS/Lifsaver"
# Menu-bar icons (loose @1x/@2x/@3x PNG files; NSImage(named:) resolves them):
# the monochrome template plus the orange alert variant for stalled volumes.
@@ -44,8 +42,7 @@ cp "$SRC_RESOURCES/AppIcon.icns" "$APP/Contents/Resources/"
sed "s/__VERSION__/$VERSION/g" scripts/release/templates/template_Info.plist >"$APP/Contents/Info.plist"
printf 'APPL????' >"$APP/Contents/PkgInfo"
-# Ad-hoc signing: helper first, then the bundle (inside-out).
-codesign --force -s - "$APP/Contents/Helpers/lifsaver"
+# Ad-hoc signing.
codesign --force -s - "$APP"
echo "Assembled $APP (version $VERSION)"
diff --git a/scripts/release/installer-scripts/postinstall b/scripts/release/installer-scripts/postinstall
deleted file mode 100755
index e41eb1c..0000000
--- a/scripts/release/installer-scripts/postinstall
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/bin/sh
-# Expose the bundled CLI on PATH.
-mkdir -p /usr/local/bin
-ln -sf "/Applications/Lifsaver.app/Contents/Helpers/lifsaver" /usr/local/bin/lifsaver
-exit 0
diff --git a/scripts/release/make_pkg.sh b/scripts/release/make_pkg.sh
index b715134..7db37bd 100755
--- a/scripts/release/make_pkg.sh
+++ b/scripts/release/make_pkg.sh
@@ -19,11 +19,8 @@ rm -rf "$PKGROOT"
mkdir -p "$PKGROOT/Applications"
cp -R "$APP" "$PKGROOT/Applications/"
-chmod 755 scripts/release/installer-scripts/postinstall
-
pkgbuild \
--root "$PKGROOT" \
- --scripts scripts/release/installer-scripts \
--identifier com.lucuma13.lifsaver \
--version "$VERSION" \
--install-location / \
diff --git a/scripts/release/templates/template_lifsaver.rb b/scripts/release/templates/template_lifsaver.rb
index 36d01e6..cfc5b1a 100644
--- a/scripts/release/templates/template_lifsaver.rb
+++ b/scripts/release/templates/template_lifsaver.rb
@@ -8,7 +8,6 @@
homepage "https://github.com/lucuma13/lifsaver"
app "Lifsaver.app"
- binary "#{appdir}/Lifsaver.app/Contents/Helpers/lifsaver", target: "lifsaver"
postflight do
if OS.mac? && File.exist?("#{appdir}/Lifsaver.app")
diff --git a/scripts/render/assets/lifsaver_logo.svg b/scripts/render/assets/lifsaver_logo.svg
index 413a06c..a2381df 100644
--- a/scripts/render/assets/lifsaver_logo.svg
+++ b/scripts/render/assets/lifsaver_logo.svg
@@ -88,21 +88,6 @@
stop-color="#D3451A"
id="stop9" />
-
-
-
-
-
" pointing right.
+ let points: [(CGFloat, CGFloat)] = [(-3.4, -3.4), (0, 0), (-3.4, 3.4)]
let path = NSBezierPath()
for (index, point) in points.enumerated() {
- let vertex = NSPoint(x: left + point.0, y: canvasHeight - (centerY - 3 + point.1))
+ let vertex = NSPoint(x: tipX + point.0, y: canvasHeight - (centerY + point.1))
if index == 0 { path.move(to: vertex) } else { path.line(to: vertex) }
}
- path.lineWidth = 1.6
+ path.lineWidth = 1.5
path.lineCapStyle = .round
path.lineJoinStyle = .round
color(hex: 0xffffff, alpha: 0.9).setStroke()
@@ -307,11 +309,11 @@ func drawMenu(_ frame: Frame) {
}
let textColor = (isMount && frame.highlight) ? NSColor.white : color(hex: 0xffffff, alpha: 0.9)
let font = isMount ? mountFont : menuFont
- // "Start at Login" is enabled in the demo — show the checkmark a real
- // macOS menu draws in the left gutter of a checked item. Hand-drawn at
- // a fixed size so it stays within the inset, clear of the text.
- if item == checkedItem {
- drawCheckmark(centerY: rowTop + rowHeight / 2)
+ // "Settings" opens a submenu — show the disclosure arrow a real
+ // macOS menu draws in the trailing edge. Hand-drawn at a fixed size so
+ // it stays within the inset, clear of the text.
+ if item == submenuItem {
+ drawSubmenuArrow(centerY: rowTop + rowHeight / 2)
}
drawText(
item, font: font, color: textColor,
diff --git a/scripts/render/social_preview.sh b/scripts/render/social_preview.sh
new file mode 100755
index 0000000..71d1d09
--- /dev/null
+++ b/scripts/render/social_preview.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+# Render the GitHub social preview (1280x640).
+#
+# Usage: social_preview.sh
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+command -v rsvg-convert >/dev/null || {
+ echo "ERROR: rsvg-convert not found (brew install librsvg)" >&2
+ exit 1
+}
+command -v magick >/dev/null || {
+ echo "ERROR: magick not found (brew install imagemagick)" >&2
+ exit 1
+}
+
+OUT="$ROOT/docs/images/lifsaver_social_preview.png"
+mkdir -p "$(dirname "$OUT")"
+
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+# 1024 buoy artwork, scaled to ~500px and centred on a 1280x640 canvas.
+# s = 500/1024; tx = (1280-500)/2; ty = (640-500)/2.
+cat >"$TMP/social.svg" <<'SVG'
+
+
+SVG
+
+rsvg-convert -w 1280 -h 640 "$TMP/social.svg" |
+ magick - -background none \
+ +set date:create +set date:modify +set date:timestamp \
+ -define png:exclude-chunk=tIME \
+ "$OUT"
+echo "Wrote $OUT"
diff --git a/scripts/version.sh b/scripts/version.sh
index 56f2ac8..2ac4ff9 100755
--- a/scripts/version.sh
+++ b/scripts/version.sh
@@ -7,7 +7,7 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-VERSION="$(sed -n 's/^public let lifsaverVersion = "\(.*\)"$/\1/p' "$ROOT/Sources/LifsaverCore/Version.swift")"
+VERSION="$(sed -n 's/^public let lifsaverVersion = "\(.*\)"$/\1/p' "$ROOT/Sources/LifsaverKit/Version.swift")"
[ -n "$VERSION" ] || {
echo "ERROR: could not read version from Version.swift" >&2
exit 1