From 9913746b7c36cfc614233844d04a58add2ef779c Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 25 Sep 2026 02:07:12 -0400 Subject: [PATCH 1/5] feat(desktop): add a Storage view, autopilot cleanup, and pressure notifications --- apps/desktop/README.md | 62 ++- .../Sources/StimDesktop/ActionCenter.swift | 52 ++- .../Sources/StimDesktop/AutopilotRunner.swift | 196 ++++++++++ .../Sources/StimDesktop/MetricsStore.swift | 43 +- .../Sources/StimDesktop/Notifier.swift | 58 +++ .../Sources/StimDesktop/StimDesktopApp.swift | 17 +- .../Sources/StimDesktop/StorageStore.swift | 112 ++++++ .../StimDesktop/Views/ActivitySheet.swift | 16 +- .../Views/AppPreferencesView.swift | 74 ++++ .../Sources/StimDesktop/Views/RootView.swift | 25 +- .../Sources/StimDesktop/Views/Sidebar.swift | 10 + .../StimDesktop/Views/StorageView.swift | 369 ++++++++++++++++++ .../Sources/StimKit/AppPreferences.swift | 23 ++ apps/desktop/Sources/StimKit/Autopilot.swift | 149 +++++++ apps/desktop/Sources/StimKit/GcPreview.swift | 9 + apps/desktop/Sources/StimKit/Resources.swift | 100 +++-- .../Sources/StimKit/SettingsState.swift | 4 + apps/desktop/Sources/StimKit/Status.swift | 8 + apps/desktop/Sources/StimKit/Storage.swift | 222 +++++++++++ .../Tests/StimKitTests/StorageTests.swift | 201 ++++++++++ 20 files changed, 1683 insertions(+), 67 deletions(-) create mode 100644 apps/desktop/Sources/StimDesktop/AutopilotRunner.swift create mode 100644 apps/desktop/Sources/StimDesktop/StorageStore.swift create mode 100644 apps/desktop/Sources/StimDesktop/Views/StorageView.swift create mode 100644 apps/desktop/Sources/StimKit/Autopilot.swift create mode 100644 apps/desktop/Sources/StimKit/Storage.swift create mode 100644 apps/desktop/Tests/StimKitTests/StorageTests.swift diff --git a/apps/desktop/README.md b/apps/desktop/README.md index feb59df4c..9ec70d31e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -58,6 +58,64 @@ on the volumes holding the repositories, `$STIM_HOME`, and CoreSimulator, and what `stim gc --delete` would reclaim; the reclaimable figure needs a Stim version with `gc --json`. +## Storage + +**Storage** under Machine in the sidebar shows what uses disk space. It never +blocks on a measurement: sizes load in the background at low priority, are kept +for 15 minutes, and **Refresh** measures again. + +- **Workspaces**: each workspace's build outputs, from `stim gc --json`, and its + `node_modules` and owned simulators and emulators, which the app sizes with + `du` (`~/Library/Developer/CoreSimulator/Devices/` and + `~/.android/avd/.avd`, or `ANDROID_AVD_HOME`). A trash icon marks build + outputs `stim gc --delete` clears; a lock marks ones it keeps, with the reason. + The lifecycle column reads **Merged into main** from `stim gc --json`, **PR #n + open** from `gh pr list` in the repository when the GitHub CLI is on the login + shell's `PATH` and signed in, **Stale Nd** after 7 days without recorded use, + or **Active**. Without `gh` the column still shows merged and stale. + **Remove merged worktrees** runs `stim worktree remove ` for each + worktree gc reports as merged, after a confirmation. A row's menu reveals the + worktree in Finder or runs `stim worktree remove` in it. +- **Stim caches and devices**: build outputs of idle workspaces, each shared + cache, and parked, orphaned or stale owned devices, from `stim gc --json`. + Each row previews a scoped dry run (`stim gc --json --cache workspaces`, + `stim gc --json --cache `, or `stim gc --json`) in the activity sheet, + whose **Delete** runs the same scope with `--delete`. +- **Outside Stim**: simulators Stim does not own, Xcode DerivedData, Gradle + caches and `~/Library/Caches`, measured with `du` and shown for information + with **Reveal in Finder**. A Stim cache inside one of them is subtracted and + listed under Stim instead. +- **Reclaim everything safe** previews `stim gc --json` and runs `stim gc +--delete` after a confirmation. + +## Autopilot + +The **Autopilot** section of the App preferences is on by default. It checks +every minute while the app runs, uses the same action slot as the cleanup the +user starts, so the two never overlap, and records every run with its exit +status under **Autopilot activity**. + +- **Shut down idle devices** after 30 minutes to 4 hours (1 hour by default) + runs `stim gc --idle m` when `stim status` shows a booted device idle + that long. It waits while a device the CLI counts as idle has a screen the app + saw change more recently, because `gc --idle` would shut that device down too. +- **Clean up every night** runs `stim gc --delete` at the chosen hour (3:00 by + default), or at the next check when the Mac slept through it. The first launch + only records the time, so installing the app never starts a cleanup. +- **Reclaim space when free disk is under the Stim budget** compares the free + space on the volumes Stim writes to, without purgeable space, with + `budget.minFreeDiskGb` and `budget.hardFloorDiskGb` from `stim settings --json`. + 0 turns the check off, as in the CLI. Under it, the app previews `stim gc +--json` and runs `stim gc --delete` at most once an hour. + +While free disk is under the budget, the Storage view shows the plan, such as +"Clear the build outputs of 3 idle workspaces and remove 1 merged worktree to +free about 300 MB", with a **Do it** button that runs `stim gc --delete`, and +the sidebar marks Storage. With autopilot reclaiming, the app posts a +notification after each run. Without it, the app posts the plan once per +episode with a **Do it** button. The **Free disk falls under the Stim budget** +notification is on by default and needs the bundled app. + ## Logs A workspace's detail view has a **Logs** tab next to its device, and the error @@ -140,7 +198,7 @@ config: appearance (Auto, Light, Dark), showing idle workspaces, opening to all devices or the last project, device tile size, a live frame rate cap, pausing frames while the window is hidden, the editor and terminal the workspace inspector opens, notifications, a menu bar extra with the live workspace count -and quick open, launch at login, and a `stim` executable override that applies +and quick open, launch at login, the autopilot (see Autopilot), and a `stim` executable override that applies at the next launch. Notifications and launch at login need the bundled app. ## Requirements @@ -171,7 +229,7 @@ The bundle copies Inter, JetBrains Mono, and the brand artwork from `website/`. ## Layout -- `Sources/StimKit`: models for the CLI's JSON, the login shell environment, the CLI client, project grouping, warning remedies, the streaming runner, `stim logs` records and the follow runner, and process, disk and gc usage. Unit-tested. +- `Sources/StimKit`: models for the CLI's JSON, the login shell environment, the CLI client, project grouping, warning remedies, the streaming runner, `stim logs` records and the follow runner, process, disk and gc usage, the Storage report and worktree lifecycle, and the autopilot schedule, pressure plan and log. Unit-tested. - `Sources/SimulatorFrames`: live simulator frames through CoreSimulator and input through SimulatorKit, both private Apple frameworks. Expect Xcode releases to break it. - `Support/SimFold`: the `sim-fold` helper, an iOS Simulator executable that `scripts/bundle.sh` builds into the app's resources. - `Sources/EmulatorFrames`: live emulator frames through the emulator's localhost gRPC `streamScreenshot` call, found through its discovery file, and input through the same endpoint. Emulators Stim booted before it passed `-grpc` show no frames until their next boot. diff --git a/apps/desktop/Sources/StimDesktop/ActionCenter.swift b/apps/desktop/Sources/StimDesktop/ActionCenter.swift index 129a0f23f..95263305f 100644 --- a/apps/desktop/Sources/StimDesktop/ActionCenter.swift +++ b/apps/desktop/Sources/StimDesktop/ActionCenter.swift @@ -1,27 +1,42 @@ import Foundation import StimKit +/// One or more Stim commands run one after another. The exit status is the first non-zero status, and a +/// failed step does not stop the ones after it. @MainActor final class ActionRun: ObservableObject, Identifiable { let id = UUID() let title: String - let command: StimCommand + let steps: [StimCommand] @Published private(set) var lines: [OutputLine] = [] @Published private(set) var exitStatus: Int32? @Published private(set) var launchError: String? - init(title: String, command: StimCommand) { + init(title: String, steps: [StimCommand]) { self.title = title - self.command = command + self.steps = steps } + var command: StimCommand { steps[0] } + var isRunning: Bool { exitStatus == nil && launchError == nil } var stdout: Data { Data(lines.filter { $0.channel == .stdout }.map(\.text).joined(separator: "\n").utf8) } + /// The last line the command printed, for the autopilot log. + var summary: String? { + launchError ?? lines.last { !$0.text.trimmingCharacters(in: .whitespaces).isEmpty && !$0.text.hasPrefix("$ ") }?.text + } + fileprivate func start(cli: StimCLI, onFinish: @escaping @MainActor () -> Void) { + start(step: 0, cli: cli, worst: 0, onFinish: onFinish) + } + + private func start(step: Int, cli: StimCLI, worst: Int32, onFinish: @escaping @MainActor () -> Void) { + let command = steps[step] + if steps.count > 1 { lines.append(OutputLine(.stderr, "$ stim \(command.arguments.joined(separator: " "))")) } // ProcessStream calls back on background queues in order; the main queue // keeps that order, where unstructured Tasks would not. do { @@ -33,8 +48,13 @@ final class ActionRun: ObservableObject, Identifiable { onExit: { status in DispatchQueue.main.async { MainActor.assumeIsolated { - self.exitStatus = status - onFinish() + let worst = worst != 0 ? worst : status + if step + 1 < self.steps.count { + self.start(step: step + 1, cli: cli, worst: worst, onFinish: onFinish) + } else { + self.exitStatus = worst + onFinish() + } } } }) @@ -66,20 +86,32 @@ final class ActionCenter: ObservableObject { func latest(for key: String) -> ActionRun? { runs[key] } func run(_ title: String, _ command: StimCommand, key: String? = nil) { - let key = key ?? command.cwd + run(title, steps: [command], key: key) + } + + /// Starts `steps` unless a run already holds `key`. With `present`, the activity sheet shows the new + /// run, or the one already running. Returns the new run, or nil when one was already running. + @discardableResult + func run( + _ title: String, steps: [StimCommand], key: String? = nil, present: Bool = true, + completion: ((ActionRun) -> Void)? = nil + ) -> ActionRun? { + let key = key ?? steps[0].cwd if let active = active(for: key) { - presented = active - return + if present { presented = active } + return nil } - let run = ActionRun(title: title, command: command) + let run = ActionRun(title: title, steps: steps) runs[key] = run - presented = run + if present { presented = run } let cli = cli Task { [weak self] in run.start(cli: await cli.value) { [weak self] in self?.objectWillChange.send() self?.onFinish?() + completion?(run) } } + return run } } diff --git a/apps/desktop/Sources/StimDesktop/AutopilotRunner.swift b/apps/desktop/Sources/StimDesktop/AutopilotRunner.swift new file mode 100644 index 000000000..f22550139 --- /dev/null +++ b/apps/desktop/Sources/StimDesktop/AutopilotRunner.swift @@ -0,0 +1,196 @@ +import AppKit +import StimKit + +/// Runs Stim's cleanup commands on a schedule while the app runs: `stim gc --idle` for idle devices, +/// a nightly `stim gc --delete`, and `stim gc --delete` when free disk falls under the Stim budget. +/// Every run goes through the CLI on the machine action slot, so it never overlaps a cleanup the user +/// started, and is recorded in the activity log. +@MainActor +final class AutopilotRunner: ObservableObject { + static let tick: TimeInterval = 60 + static let idleRetry: TimeInterval = 10 * 60 + static let pressureRetry: TimeInterval = 60 * 60 + static let budgetMaxAge: TimeInterval = 30 * 60 + static let reportMaxAge: TimeInterval = 10 * 60 + + @Published private(set) var log: [AutopilotLogEntry] + /// Set while free disk is under the Stim budget. + @Published private(set) var pressure: PressurePlan? + /// The volume Stim writes to with the least free space, measured on every check. + @Published private(set) var lowestVolume: DiskVolume? + + private let status: StatusStore + private let actions: ActionCenter + private let cli: Task + private var timer: Timer? + private var checking = false + private var lastIdleRun: Date? + private var lastPressureRun: Date? + private var budget: (minFree: Double, hardFloor: Double)? + private var budgetAt: Date? + private var report: GcReport? + private var reportAt: Date? + private var notifiedEpisode = false + + init(status: StatusStore, actions: ActionCenter, cli: Task) { + self.status = status + self.actions = actions + self.cli = cli + log = AutopilotLog.decode(UserDefaults.standard.data(forKey: AppPreferences.Key.autopilotLog)) + } + + private var defaults: UserDefaults { .standard } + + func start() { + guard timer == nil else { return } + if defaults.object(forKey: AppPreferences.Key.autopilotLastNightly) == nil { + defaults.set(Date(), forKey: AppPreferences.Key.autopilotLastNightly) + } + NotificationResponder.shared.runPlan = { [weak self] in + NSApp.activate(ignoringOtherApps: true) + self?.runPressurePlan(trigger: .manual, present: true) + } + if defaults.bool(forKey: AppPreferences.Key.notifiesDiskPressure) { Notifier.requestAuthorization() } + timer = Timer.scheduledTimer(withTimeInterval: Self.tick, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.check() } + } + Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in + MainActor.assumeIsolated { self?.check() } + } + } + + func clearLog() { + log = [] + defaults.removeObject(forKey: AppPreferences.Key.autopilotLog) + } + + /// Runs `stim gc --delete`, which is what the pressure plan proposes. + func runPressurePlan(trigger: AutopilotLogEntry.Trigger, present: Bool) { + lastPressureRun = Date() + run(trigger, "Reclaim disk space", PressurePlan.arguments, present: present) { [weak self] run in + guard trigger == .pressure, let self, let plan = self.pressure else { return } + Notifier.postPressure( + id: "pressure-ran-\(run.id)", title: "Stim Desktop ran stim gc --delete", + body: "\(plan.headline). \(run.exitStatus == 0 ? "It finished" : "It exited with an error"); see the autopilot log.", + offersPlan: false) + } + } + + private func check() { + let now = Date() + let idle = actions.active(for: ActionCenter.machineKey) == nil + if idle, defaults.bool(forKey: AppPreferences.Key.autopilotNightly), + let last = defaults.object(forKey: AppPreferences.Key.autopilotLastNightly) as? Date, + AutopilotSchedule.nightlyDue( + now: now, hour: defaults.integer(forKey: AppPreferences.Key.autopilotNightlyHour), lastRun: last) + { + defaults.set(now, forKey: AppPreferences.Key.autopilotLastNightly) + run(.nightly, "Nightly cleanup", ["gc", "--delete"], present: false) + return + } + let minutes = defaults.integer(forKey: AppPreferences.Key.autopilotIdleMinutes) + if idle, defaults.bool(forKey: AppPreferences.Key.autopilotIdleShutdown), minutes > 0, + lastIdleRun.map({ now.timeIntervalSince($0) >= Self.idleRetry }) ?? true, + AutopilotSchedule.idleShutdownDue(bootedDevices, minutes: minutes, now: now) + { + lastIdleRun = now + run(.idle, "Shut down idle devices", ["gc", "--idle", AutopilotSchedule.idleDuration(minutes: minutes)], present: false) + return + } + checkPressure() + } + + private var bootedDevices: [AutopilotSchedule.Device] { + (status.payload?.environments ?? []).flatMap(\.devices).filter(\.isRunning).map { + AutopilotSchedule.Device(activity: $0.activity, screenChangedAt: $0.activityKey.flatMap(ScreenActivity.shared.lastChange)) + } + } + + private func checkPressure() { + guard !checking else { return } + checking = true + let now = Date() + let locations = stimDiskLocations(status.payload?.environments ?? [], status: status) + let budget = budgetAt.map { now.timeIntervalSince($0) < Self.budgetMaxAge } == true ? self.budget : nil + let report = reportAt.map { now.timeIntervalSince($0) < Self.reportMaxAge } == true ? self.report : nil + let cli = cli + Task.detached(priority: .utility) { + let cli = await cli.value + let volumes = DiskUsage.volumes(for: locations) + let lowest = volumes.min { $0.availableBytes < $1.availableBytes } + let free = volumes.map { $0.unpurgeableFreeBytes ?? $0.availableBytes }.min() + let limits = + budget + ?? (try? cli.settings(cwd: NSHomeDirectory())).map { settings in + ( + minFree: settings.entry("budget.minFreeDiskGb")?.number ?? 0, + hardFloor: settings.entry("budget.hardFloorDiskGb")?.number ?? 0 + ) + } + var plan = free.flatMap { free in + limits.flatMap { PressurePlan.make(freeBytes: free, minimumFreeGb: $0.minFree, hardFloorGb: $0.hardFloor, report: nil) } + } + var fresh = report + if plan != nil, fresh == nil { fresh = try? cli.gcReport() } + if let free, let limits, plan != nil { + plan = PressurePlan.make(freeBytes: free, minimumFreeGb: limits.minFree, hardFloorGb: limits.hardFloor, report: fresh) + } + let result = plan + let checkedReport = fresh + await MainActor.run { + self.checking = false + self.lowestVolume = lowest + if budget == nil, let limits { + self.budget = limits + self.budgetAt = now + } + if report == nil, let checkedReport { + self.report = checkedReport + self.reportAt = now + } + self.pressure = result + self.react(to: result) + } + } + } + + private func react(to plan: PressurePlan?) { + guard let plan else { + notifiedEpisode = false + return + } + let acts = defaults.bool(forKey: AppPreferences.Key.autopilotPressure) + if acts, !plan.isEmpty, actions.active(for: ActionCenter.machineKey) == nil, + lastPressureRun.map({ Date().timeIntervalSince($0) >= Self.pressureRetry }) ?? true + { + runPressurePlan(trigger: .pressure, present: false) + return + } + guard !notifiedEpisode, !(acts && !plan.isEmpty) else { return } + notifiedEpisode = true + Notifier.postPressure( + id: "pressure-\(Date().timeIntervalSince1970)", title: plan.headline, body: plan.proposal, offersPlan: !plan.isEmpty) + } + + private func run( + _ trigger: AutopilotLogEntry.Trigger, _ title: String, _ arguments: [String], present: Bool, + completion: ((ActionRun) -> Void)? = nil + ) { + let command = StimCommand(arguments, cwd: NSHomeDirectory()) + actions.run(title, steps: [command], key: ActionCenter.machineKey, present: present) { [weak self] run in + guard let self else { return } + self.reportAt = nil + self.checkPressure() + self.record( + AutopilotLogEntry( + date: Date(), trigger: trigger, command: "stim \(arguments.joined(separator: " "))", + exitStatus: run.launchError == nil ? run.exitStatus : nil, note: run.summary)) + completion?(run) + } + } + + private func record(_ entry: AutopilotLogEntry) { + log = AutopilotLog.appending(entry, to: log) + defaults.set(AutopilotLog.encode(log), forKey: AppPreferences.Key.autopilotLog) + } +} diff --git a/apps/desktop/Sources/StimDesktop/MetricsStore.swift b/apps/desktop/Sources/StimDesktop/MetricsStore.swift index 24133d48b..c0bcd9775 100644 --- a/apps/desktop/Sources/StimDesktop/MetricsStore.swift +++ b/apps/desktop/Sources/StimDesktop/MetricsStore.swift @@ -19,15 +19,15 @@ struct UsageHistory { final class MetricsStore: ObservableObject { @Published private(set) var usage: [String: UsageHistory] = [:] @Published private(set) var volumes: [DiskVolume] = [] - @Published private(set) var reclaimable: GcReport.Reclaimable? + @Published private(set) var gcReport: GcReport? + @Published private(set) var gcRunning = false + @Published private(set) var gcAt: Date? private let status: StatusStore private let cli: Task private var sampler = ResourceSampler() private var timer: Timer? private var sampling = false - private var gcRunning = false - private var gcAt: Date? init(status: StatusStore, cli: Task) { self.status = status @@ -47,6 +47,8 @@ final class MetricsStore: ObservableObject { } } + var reclaimable: GcReport.Reclaimable? { gcReport?.reclaimable } + var totalCpu: Double? { let values = usage.values.compactMap(\.latest.cpuPercent) return values.isEmpty ? nil : values.reduce(0, +) @@ -70,7 +72,7 @@ final class MetricsStore: ObservableObject { guard !sampling, let payload = status.payload else { return } sampling = true let workspaces = payload.environments - let locations = diskLocations(workspaces) + let locations = stimDiskLocations(workspaces, status: status) let base = sampler Task.detached { let processes = (try? ProcessTable.snapshot()) ?? [] @@ -93,7 +95,9 @@ final class MetricsStore: ObservableObject { } } - private func refreshGc() { + /// Runs the `stim gc --json` dry run again unless one is running. + func refreshGc() { + guard !gcRunning else { return } gcRunning = true let cli = cli Task.detached { @@ -101,20 +105,22 @@ final class MetricsStore: ObservableObject { await MainActor.run { self.gcRunning = false self.gcAt = Date() - self.reclaimable = report?.reclaimable + self.gcReport = report } } } +} - private func diskLocations(_ workspaces: [Workspace]) -> [(label: String, path: String)] { - let home = NSHomeDirectory() - let stimHome = ProcessInfo.processInfo.environment["STIM_HOME"] ?? "\(home)/.stim" - let repositories = Set(workspaces.map { status.project(of: $0).root }).sorted() - return repositories.map { (label: "Repositories", path: $0) } + [ - (label: "Stim home", path: stimHome), - (label: "Simulators", path: "\(home)/Library/Developer/CoreSimulator"), - ] - } +/// The volumes Stim writes to: the repositories, `$STIM_HOME`, and the CoreSimulator device set. +@MainActor +func stimDiskLocations(_ workspaces: [Workspace], status: StatusStore) -> [(label: String, path: String)] { + let home = NSHomeDirectory() + let stimHome = ProcessInfo.processInfo.environment["STIM_HOME"] ?? "\(home)/.stim" + let repositories = Set(workspaces.map { status.project(of: $0).root }).sorted() + return repositories.map { (label: "Repositories", path: $0) } + [ + (label: "Stim home", path: stimHome), + (label: "Simulators", path: "\(home)/Library/Developer/CoreSimulator"), + ] } func formatMemory(_ bytes: Int64) -> String { @@ -128,3 +134,10 @@ func formatDisk(_ bytes: Int64) -> String { func formatPercent(_ percent: Double) -> String { "\(Int(percent.rounded()))%" } + +func formatAgo(_ seconds: TimeInterval) -> String { + let minutes = Int(seconds / 60) + if minutes < 1 { return "just now" } + if minutes < 60 { return "\(minutes)m ago" } + return "\(minutes / 60)h ago" +} diff --git a/apps/desktop/Sources/StimDesktop/Notifier.swift b/apps/desktop/Sources/StimDesktop/Notifier.swift index f2438f09e..b34abace2 100644 --- a/apps/desktop/Sources/StimDesktop/Notifier.swift +++ b/apps/desktop/Sources/StimDesktop/Notifier.swift @@ -27,6 +27,19 @@ final class Notifier: ObservableObject { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } } + static let pressureCategory = "diskPressure" + static let doItAction = "doIt" + + /// Posts a disk pressure notification. With `offersPlan`, it carries a Do it button that runs the plan. + static func postPressure(id: String, title: String, body: String, offersPlan: Bool) { + guard isAvailable, UserDefaults.standard.bool(forKey: AppPreferences.Key.notifiesDiskPressure) else { return } + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.categoryIdentifier = offersPlan ? pressureCategory : "" + UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) + } + func start() { guard subscription == nil else { return } subscription = store.$payload.compactMap { $0 }.sink { [weak self] payload in @@ -51,3 +64,48 @@ final class Notifier: ObservableObject { } } } + +/// Handles the Do it button of a disk pressure notification, and opens the Storage view when one is clicked. +final class NotificationResponder: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable { + static let shared = NotificationResponder() + + @MainActor var runPlan: (() -> Void)? + + @MainActor func install() { + guard Notifier.isAvailable else { return } + let center = UNUserNotificationCenter.current() + center.setNotificationCategories([ + UNNotificationCategory( + identifier: Notifier.pressureCategory, + actions: [UNNotificationAction(identifier: Notifier.doItAction, title: "Do it", options: [.foreground])], + intentIdentifiers: []) + ]) + center.delegate = self + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let action = response.actionIdentifier + let id = response.notification.request.identifier + DispatchQueue.main.async { + MainActor.assumeIsolated { + if action == Notifier.doItAction { + self.runPlan?() + } else if id.hasPrefix("pressure") { + OpenRequests.shared.showsStorage = true + } + } + completionHandler() + } + } + + /// Without a delegate macOS shows no banner while the app is active; only pressure notifications change that. + func userNotificationCenter( + _ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler(notification.request.identifier.hasPrefix("pressure") ? [.banner, .sound] : []) + } +} diff --git a/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift b/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift index cef38f896..df99052e3 100644 --- a/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift +++ b/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift @@ -10,6 +10,7 @@ final class OpenRequests: ObservableObject { @Published var workspacePath: String? /// The workspace selected in the main window, which the Settings window edits. @Published var selectedWorkspace: String? + @Published var showsStorage = false } final class AppDelegate: NSObject, NSApplicationDelegate { @@ -23,6 +24,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) MainActor.assumeIsolated { + NotificationResponder.shared.install() Theme.apply(Appearance(rawValue: UserDefaults.standard.string(forKey: AppPreferences.Key.appearance) ?? "") ?? .auto) } } @@ -37,11 +39,14 @@ struct StimDesktopApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @StateObject private var store: StatusStore @StateObject private var notifier: Notifier + @StateObject private var actions: ActionCenter + @StateObject private var autopilot: AutopilotRunner @AppStorage(AppPreferences.Key.showsMenuBarExtra) private var showsMenuBarExtra = false private let cli: Task init() { BrandAssets.registerFonts() + UserDefaults.standard.register(defaults: AppPreferences.defaults) CoreSimulator.developerDir = CoreSimulator.selectedDeveloperDir() let override = UserDefaults.standard.string(forKey: AppPreferences.Key.stimExecutable) let cli = Task.detached { @@ -51,19 +56,25 @@ struct StimDesktopApp: App { let store = StatusStore(cli: cli) _store = StateObject(wrappedValue: store) _notifier = StateObject(wrappedValue: Notifier(store: store)) + let actions = ActionCenter(cli: cli) + _actions = StateObject(wrappedValue: actions) + _autopilot = StateObject(wrappedValue: AutopilotRunner(status: store, actions: actions, cli: cli)) } var body: some Scene { WindowGroup("Stim", id: "main") { - RootView(cli: cli, store: store) + RootView(cli: cli, store: store, actions: actions, autopilot: autopilot) .frame(minWidth: 1100, minHeight: 720) - .onAppear { notifier.start() } + .onAppear { + notifier.start() + autopilot.start() + } } .windowToolbarStyle(.unified(showsTitle: false)) .handlesExternalEvents(matching: []) Settings { - SettingsView(cli: cli, store: store) + SettingsView(cli: cli, store: store).environmentObject(autopilot) } MenuBarExtra(isInserted: $showsMenuBarExtra) { diff --git a/apps/desktop/Sources/StimDesktop/StorageStore.swift b/apps/desktop/Sources/StimDesktop/StorageStore.swift new file mode 100644 index 000000000..cc88c14ea --- /dev/null +++ b/apps/desktop/Sources/StimDesktop/StorageStore.swift @@ -0,0 +1,112 @@ +import Foundation +import StimKit + +/// Disk sizes Stim Desktop measures itself with `du`, and open pull requests from `gh`. Both run in the +/// background at low priority and are kept for `maxAge`, so opening the Storage view never waits on them. +@MainActor +final class StorageStore: ObservableObject { + static let maxAge: TimeInterval = 15 * 60 + nonisolated static let toolTimeout: TimeInterval = 30 + + @Published private(set) var sizes: [String: Int64] = [:] + @Published private(set) var measuredAt: Date? + @Published private(set) var measuring = false + /// Open pull requests by repository, then by head branch. + @Published private(set) var pulls: [String: [String: PullRequest]] = [:] + /// False when no `gh` is on the login shell's PATH. + @Published private(set) var hasGitHubCLI: Bool? + @Published private(set) var paths = StoragePaths(home: NSHomeDirectory()) + + private let status: StatusStore + private let cli: Task + + init(status: StatusStore, cli: Task) { + self.status = status + self.cli = cli + } + + func refresh(force: Bool = false) { + guard !measuring else { return } + if !force, let at = measuredAt, Date().timeIntervalSince(at) < Self.maxAge { return } + measuring = true + let environments = status.payload?.environments ?? [] + let repositories = Set(environments.compactMap { $0.worktree?.repository }) + let cli = cli + Task.detached(priority: .background) { + let environment = await cli.value.environment + let paths = StoragePaths(home: NSHomeDirectory(), environment: environment) + let modules = Set(environments.map { "\($0.worktree?.path ?? $0.path)/node_modules" }) + let sizes = Self.du(["-k", "-d", "1"], paths.deviceSets).merging( + Self.du(["-k", "-s"], modules.sorted() + paths.unmanaged.map(\.path)), uniquingKeysWith: { first, _ in first }) + let gh = Self.executable("gh", in: environment) + let pulls = Dictionary( + uniqueKeysWithValues: repositories.compactMap { repository -> (String, [String: PullRequest])? in + guard let gh, + let byBranch = Self.run(gh, PullRequest.listArguments, cwd: repository, environment: environment) + .flatMap(PullRequest.byBranch) + else { return nil } + return (repository, byBranch) + }) + await MainActor.run { + self.paths = paths + self.sizes = sizes + self.pulls = pulls + self.hasGitHubCLI = gh != nil + self.measuredAt = Date() + self.measuring = false + } + } + } + + func pulls(for workspace: WorkspaceStorage) -> [String: PullRequest]? { + workspace.repository.flatMap { pulls[$0] } + } + + nonisolated private static func du(_ options: [String], _ paths: [String]) -> [String: Int64] { + let existing = paths.filter { FileManager.default.fileExists(atPath: $0) } + guard !existing.isEmpty, + let output = run("/usr/bin/du", options + existing, cwd: NSHomeDirectory(), environment: nil, timeout: 600) + else { return [:] } + return DiskSizes.parse(String(decoding: output, as: UTF8.self)) + } + + nonisolated private static func executable(_ name: String, in environment: [String: String]) -> String? { + (environment["PATH"] ?? "").split(separator: ":").lazy.map { "\($0)/\(name)" } + .first { FileManager.default.isExecutableFile(atPath: $0) } + } + + /// Stdout of a finished run, or nil when it could not start or ran past `timeout`. `du` exits 1 after + /// an unreadable entry but still prints the rest, so the status is not checked. + nonisolated private static func run( + _ executable: String, _ arguments: [String], cwd: String, environment: [String: String]?, + timeout: TimeInterval = toolTimeout + ) -> Data? { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + if let environment { process.environment = environment } + process.qualityOfService = .background + let out = Pipe() + process.standardOutput = out + process.standardError = FileHandle.nullDevice + process.standardInput = FileHandle.nullDevice + guard (try? process.run()) != nil else { return nil } + let box = DataBox() + let read = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .background).async { + box.value = out.fileHandleForReading.readDataToEndOfFile() + read.signal() + } + if read.wait(timeout: .now() + timeout) == .timedOut { + process.terminate() + return nil + } + process.waitUntilExit() + return box.value + } +} + +private final class DataBox: @unchecked Sendable { + var value = Data() +} diff --git a/apps/desktop/Sources/StimDesktop/Views/ActivitySheet.swift b/apps/desktop/Sources/StimDesktop/Views/ActivitySheet.swift index 449260a46..0f5c8f3fe 100644 --- a/apps/desktop/Sources/StimDesktop/Views/ActivitySheet.swift +++ b/apps/desktop/Sources/StimDesktop/Views/ActivitySheet.swift @@ -8,10 +8,12 @@ struct ActivitySheet: View { @State private var confirmingDelete = false @State private var idleDuration: String? - private var isGcPreview: Bool { run.command.arguments == ["gc", "--json"] } + private var deleteArguments: [String]? { + run.steps.count == 1 ? GcPreview.deleteArguments(after: run.command.arguments) : nil + } private var report: Result? { - guard isGcPreview, run.exitStatus != nil else { return nil } + guard deleteArguments != nil, run.exitStatus != nil else { return nil } return Result { try GcPreview(json: run.stdout) } } @@ -22,7 +24,7 @@ struct ActivitySheet: View { Spacer() status } - CommandText(command: run.command.shellLine) + CommandText(command: run.steps.map(\.shellLine).joined(separator: "\n")) if case .success(let report) = report { GcPreviewView(report: report) @@ -39,17 +41,17 @@ struct ActivitySheet: View { Text("Closing this keeps the command running.").foregroundStyle(Theme.tertiary) } Spacer() - if case .success(let report) = report, !report.idleDevices.isEmpty { + if case .success(let report) = report, run.command.arguments == ["gc", "--json"], !report.idleDevices.isEmpty { idleMenu(report) } - if case .success(let report) = report { + if case .success(let report) = report, let deleteArguments { Button("Delete\u{2026}", role: .destructive) { confirmingDelete = true } .disabled(!report.actionable) .confirmationDialog( "Delete what stim gc reported?", isPresented: $confirmingDelete, titleVisibility: .visible ) { - Button("Run stim gc --delete", role: .destructive) { - actions.run("Clean up", StimCommand(["gc", "--delete"], cwd: run.command.cwd), key: ActionCenter.machineKey) + Button("Run stim \(deleteArguments.joined(separator: " "))", role: .destructive) { + actions.run("Clean up", StimCommand(deleteArguments, cwd: run.command.cwd), key: ActionCenter.machineKey) } } message: { Text(deleteMessage(report)) diff --git a/apps/desktop/Sources/StimDesktop/Views/AppPreferencesView.swift b/apps/desktop/Sources/StimDesktop/Views/AppPreferencesView.swift index ad7616b3c..49c3af125 100644 --- a/apps/desktop/Sources/StimDesktop/Views/AppPreferencesView.swift +++ b/apps/desktop/Sources/StimDesktop/Views/AppPreferencesView.swift @@ -15,6 +15,13 @@ struct AppPreferencesView: View { @AppStorage(AppPreferences.Key.showsMenuBarExtra) private var showsMenuBarExtra = false @AppStorage(AppPreferences.Key.stimExecutable) private var stimExecutable = "" @AppStorage(AppPreferences.Key.remoteSessionMinutes) private var remoteMinutes = 30 + @AppStorage(AppPreferences.Key.autopilotIdleShutdown) private var idleShutdown = true + @AppStorage(AppPreferences.Key.autopilotIdleMinutes) private var idleMinutes = 60 + @AppStorage(AppPreferences.Key.autopilotNightly) private var nightly = true + @AppStorage(AppPreferences.Key.autopilotNightlyHour) private var nightlyHour = 3 + @AppStorage(AppPreferences.Key.autopilotPressure) private var actsOnPressure = true + @AppStorage(AppPreferences.Key.notifiesDiskPressure) private var notifiesPressure = true + @EnvironmentObject private var autopilot: AutopilotRunner @State private var launchesAtLogin = SMAppService.mainApp.status == .enabled @State private var loginError: String? @@ -50,6 +57,47 @@ struct AppPreferencesView: View { appPicker("Open in Terminal", selection: $terminal, apps: ExternalApp.terminals) } + Section { + Toggle("Shut down idle devices", isOn: $idleShutdown) + Picker("After", selection: $idleMinutes) { + ForEach(AppPreferences.idleMinuteChoices, id: \.self) { minutes in + Text(minutes < 60 ? "\(minutes) min" : "\(minutes / 60) h").tag(minutes) + } + } + .disabled(!idleShutdown) + Toggle("Clean up every night", isOn: $nightly) + Picker("At", selection: $nightlyHour) { + ForEach(0..<24, id: \.self) { hour in Text(String(format: "%02d:00", hour)).tag(hour) } + } + .disabled(!nightly) + Toggle("Reclaim space when free disk is under the Stim budget", isOn: $actsOnPressure) + } header: { + Text("Autopilot") + } footer: { + Text( + "Idle shutdown runs stim gc --idle, which shuts owned simulators and emulators down and never deletes them. A device whose screen changed in this app is left running. Nightly cleanup and disk pressure run stim gc --delete, which removes only what stim gc reports as safe: build outputs of workspaces not in use, merged worktrees, and unused owned devices. The budget is budget.minFreeDiskGb. A nightly run the Mac slept through runs at the next check." + ) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(Theme.tertiary) + } + + Section { + if autopilot.log.isEmpty { + Text("No runs yet.").foregroundStyle(Theme.tertiary) + } else { + ForEach(autopilot.log.prefix(50)) { entry in AutopilotLogRow(entry: entry) } + } + } header: { + HStack { + Text("Autopilot activity") + Spacer() + if !autopilot.log.isEmpty { + Button("Clear") { autopilot.clearLog() }.controlSize(.small) + } + } + } + Section("Notifications") { if !Notifier.isAvailable { Text("Notifications need the bundled app; `swift run` cannot post them.") @@ -58,6 +106,9 @@ struct AppPreferencesView: View { ForEach(StatusEvent.Kind.allCases, id: \.self) { kind in NotificationToggle(kind: kind) } + Toggle("Free disk falls under the Stim budget", isOn: $notifiesPressure) + .disabled(!Notifier.isAvailable) + .onChange(of: notifiesPressure) { _, on in if on { Notifier.requestAuthorization() } } Stepper("Remote session reminder after \(remoteMinutes) min", value: $remoteMinutes, in: 5...240, step: 5) } @@ -130,3 +181,26 @@ private struct NotificationToggle: View { .onChange(of: enabled) { _, on in if on { Notifier.requestAuthorization() } } } } + +private struct AutopilotLogRow: View { + var entry: AutopilotLogEntry + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: entry.exitStatus == 0 ? "checkmark.circle.fill" : "xmark.octagon.fill") + .foregroundStyle(entry.exitStatus == 0 ? Theme.live : Theme.error) + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(entry.trigger.title) + Spacer() + Text(entry.date.formatted(date: .abbreviated, time: .shortened)).foregroundStyle(Theme.tertiary) + } + Text(entry.command).font(Theme.mono()).foregroundStyle(Theme.secondary) + if let note = entry.note { + Text(note).font(Theme.body(11.5)).foregroundStyle(Theme.tertiary).lineLimit(2) + } + } + } + .textSelection(.enabled) + } +} diff --git a/apps/desktop/Sources/StimDesktop/Views/RootView.swift b/apps/desktop/Sources/StimDesktop/Views/RootView.swift index 31d6c9253..bc888d0ce 100644 --- a/apps/desktop/Sources/StimDesktop/Views/RootView.swift +++ b/apps/desktop/Sources/StimDesktop/Views/RootView.swift @@ -7,12 +7,15 @@ enum SidebarItem: Hashable { case environment(String) case worktree(String) case attention + case storage } struct RootView: View { @ObservedObject private var store: StatusStore @StateObject private var metrics: MetricsStore - @StateObject private var actions: ActionCenter + @ObservedObject private var actions: ActionCenter + @ObservedObject private var autopilot: AutopilotRunner + @StateObject private var storage: StorageStore @State private var selection: SidebarItem? = .wall @State private var restoredProject = false @AppStorage(AppPreferences.Key.defaultView) private var defaultView = DefaultView.allDevices @@ -25,16 +28,18 @@ struct RootView: View { private let cli: Task - init(cli: Task, store: StatusStore) { + init(cli: Task, store: StatusStore, actions: ActionCenter, autopilot: AutopilotRunner) { self.cli = cli self.store = store + self.actions = actions + self.autopilot = autopilot _metrics = StateObject(wrappedValue: MetricsStore(status: store, cli: cli)) - _actions = StateObject(wrappedValue: ActionCenter(cli: cli)) + _storage = StateObject(wrappedValue: StorageStore(status: store, cli: cli)) } var body: some View { NavigationSplitView { - Sidebar(store: store, selection: $selection, projectFilter: projectFilter) + Sidebar(store: store, autopilot: autopilot, selection: $selection, projectFilter: projectFilter) .navigationSplitViewColumnWidth(min: 240, ideal: 272) } detail: { detail @@ -53,7 +58,10 @@ struct RootView: View { ActivitySheet(run: run).environmentObject(actions) } .onAppear { - actions.onFinish = store.refresh + actions.onFinish = { [store, metrics] in + store.refresh() + metrics.refreshGc() + } store.start() metrics.start() } @@ -66,6 +74,11 @@ struct RootView: View { restoreLastProject() } .onReceive(store.$projects) { _ in restoreLastProject() } + .onReceive(openRequests.$showsStorage) { shows in + guard shows else { return } + openRequests.showsStorage = false + selection = .storage + } .onReceive(openRequests.$workspacePath) { path in guard let path else { return } openRequests.workspacePath = nil @@ -130,6 +143,8 @@ struct RootView: View { } case .attention: AttentionView(store: store) + case .storage: + StorageView(status: store, metrics: metrics, storage: storage, autopilot: autopilot) default: WallView(store: store, metrics: metrics, project: projectFilter, selection: $selection, openLogs: openErrors) } diff --git a/apps/desktop/Sources/StimDesktop/Views/Sidebar.swift b/apps/desktop/Sources/StimDesktop/Views/Sidebar.swift index 287e26c86..419586008 100644 --- a/apps/desktop/Sources/StimDesktop/Views/Sidebar.swift +++ b/apps/desktop/Sources/StimDesktop/Views/Sidebar.swift @@ -3,6 +3,7 @@ import SwiftUI struct Sidebar: View { @ObservedObject var store: StatusStore + @ObservedObject var autopilot: AutopilotRunner @Binding var selection: SidebarItem? var projectFilter: Project? @AppStorage(AppPreferences.Key.showsIdleWorkspaces) private var showsIdle = true @@ -58,6 +59,15 @@ struct Sidebar: View { } } .sidebarTag(.attention, selection: selection) + HStack { + Label("Storage", systemImage: "internaldrive") + Spacer() + if autopilot.pressure != nil { + Image(systemName: "exclamationmark.circle.fill").font(.system(size: 11)).foregroundStyle(Theme.warn) + .help("Free disk is under the Stim budget") + } + } + .sidebarTag(.storage, selection: selection) } } .scrollContentBackground(.hidden) diff --git a/apps/desktop/Sources/StimDesktop/Views/StorageView.swift b/apps/desktop/Sources/StimDesktop/Views/StorageView.swift new file mode 100644 index 000000000..965c2dbf2 --- /dev/null +++ b/apps/desktop/Sources/StimDesktop/Views/StorageView.swift @@ -0,0 +1,369 @@ +import AppKit +import StimKit +import SwiftUI + +struct StorageView: View { + @ObservedObject var status: StatusStore + @ObservedObject var metrics: MetricsStore + @ObservedObject var storage: StorageStore + @ObservedObject var autopilot: AutopilotRunner + @EnvironmentObject private var actions: ActionCenter + @State private var confirmingMerged = false + @State private var removing: WorkspaceStorage? + + private static let sizeWidth: CGFloat = 92 + + var body: some View { + let report = StorageReport.make( + environments: status.payload?.environments ?? [], gc: metrics.gcReport, sizes: storage.sizes, + paths: storage.paths) + ScrollView { + VStack(alignment: .leading, spacing: 28) { + header + if let plan = autopilot.pressure { pressureBanner(plan) } + summary(report) + workspaces(report) + stim(report) + unmanaged(report) + } + .padding(28) + .frame(maxWidth: .infinity, alignment: .leading) + } + .onAppear { + storage.refresh() + if metrics.gcReport == nil { metrics.refreshGc() } + } + } + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text("Storage").font(Theme.heading(22)) + Text("What Stim workspaces, caches and devices use on disk, and what else fills it.") + .foregroundStyle(Theme.secondary) + } + Spacer() + if storage.measuring || metrics.gcRunning { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Measuring in the background").foregroundStyle(Theme.tertiary) + } + } else if let at = storage.measuredAt { + TimelineView(.periodic(from: .now, by: 30)) { context in + Text("Measured \(formatAgo(context.date.timeIntervalSince(at)))").foregroundStyle(Theme.tertiary) + } + } + Button("Refresh") { + storage.refresh(force: true) + metrics.refreshGc() + } + .disabled(storage.measuring || metrics.gcRunning) + } + } + + private func pressureBanner(_ plan: PressurePlan) -> some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Theme.warn).font(.system(size: 18)) + VStack(alignment: .leading, spacing: 4) { + Text(plan.headline).font(Theme.body(13, weight: .semibold)) + Text(plan.proposal).foregroundStyle(Theme.secondary) + if plan.belowHardFloor { + Text("Below the hard floor, stim start, ios and android refuse with STIM_LOW_DISK.") + .foregroundStyle(Theme.warn) + } + } + Spacer() + if !plan.isEmpty { + Button("Do it") { autopilot.runPressurePlan(trigger: .manual, present: true) } + .buttonStyle(.borderedProminent) + .help("stim gc --delete") + } + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 12).fill(Theme.warn.opacity(0.12))) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Theme.warn.opacity(0.35))) + } + + private func summary(_ report: StorageReport) -> some View { + let lowest = metrics.volumes.min { $0.availableBytes < $1.availableBytes } ?? autopilot.lowestVolume + let workspaceBytes = report.workspaces.reduce(Int64(0)) { $0 + $1.total } + let cacheBytes = report.caches.compactMap(\.bytes).reduce(0, +) + return HStack(spacing: 14) { + tile("Free", lowest.map { formatDisk($0.availableBytes) } ?? "\u{2014}", detail: lowest?.name) + tile("Workspaces", formatDisk(workspaceBytes), detail: "\(report.workspaces.count) environments") + tile("Stim caches", formatDisk(cacheBytes), detail: "\(report.caches.count) shared caches") + VStack(alignment: .leading, spacing: 8) { + SectionLabel(title: "Reclaimable now") + Text(metrics.reclaimable.map { formatDisk($0.bytes) } ?? "\u{2014}").font(Theme.heading(20)) + .foregroundStyle(Theme.primary) + previewButton("Reclaim everything safe\u{2026}", ["gc", "--json"]) + .help("Preview stim gc, then run stim gc --delete after a confirmation") + } + .padding(16) + .frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading) + .background(RoundedRectangle(cornerRadius: 12).fill(Theme.surface)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Theme.border)) + } + } + + private func tile(_ title: String, _ value: String, detail: String?) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionLabel(title: title) + Text(value).font(Theme.heading(20)) + if let detail { Text(detail).font(Theme.body(11.5)).foregroundStyle(Theme.tertiary).lineLimit(1) } + } + .padding(16) + .frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading) + .background(RoundedRectangle(cornerRadius: 12).fill(Theme.surface)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Theme.border)) + } + + private func workspaces(_ report: StorageReport) -> some View { + let merged = metrics.gcReport?.mergedWorktrees ?? [] + return VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Workspaces").font(Theme.heading(15)) + Text("\(report.workspaces.count)").foregroundStyle(Theme.tertiary) + Spacer() + if storage.hasGitHubCLI == false { + Text("Install the GitHub CLI (gh) to show open pull requests.").font(Theme.body(11.5)) + .foregroundStyle(Theme.tertiary) + } + Button("Remove merged worktrees (\(merged.count))\u{2026}") { confirmingMerged = true } + .disabled(merged.isEmpty || actions.active(for: ActionCenter.machineKey) != nil) + .help("stim worktree remove on each worktree stim gc reports as merged") + .confirmationDialog( + "Remove \(merged.count == 1 ? "1 merged worktree" : "\(merged.count) merged worktrees")?", + isPresented: $confirmingMerged, titleVisibility: .visible + ) { + Button("Run stim worktree remove", role: .destructive) { removeMerged(merged, report: report) } + } message: { + Text( + merged.map { PathNames(path: $0.path).title }.joined(separator: ", ") + + ". Each branch is merged into the default branch. stim worktree remove checks each one again and keeps any with uncommitted or unpushed work." + ) + } + } + if report.workspaces.isEmpty { + Text("stim status reports no workspaces.").foregroundStyle(Theme.tertiary) + } else { + Card { + VStack(spacing: 0) { + columnHeader + ForEach(report.workspaces) { workspace in + Rectangle().fill(Theme.border).frame(height: 1) + workspaceRow(workspace) + } + } + } + } + } + .confirmationDialog( + "Remove this worktree?", + isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), + titleVisibility: .visible, presenting: removing + ) { workspace in + Button("Run stim worktree remove", role: .destructive) { + actions.run("Remove \(PathNames(path: workspace.path).title)", StimCommand(["worktree", "remove"], cwd: workspace.path)) + } + } message: { workspace in + Text( + "Stim reclaims its build outputs, owned devices and Metro port, then removes the checkout\(workspace.branch.map { " of \($0)" } ?? ""). It refuses when the worktree has uncommitted or unpushed work." + ) + } + } + + private var columnHeader: some View { + HStack(spacing: 12) { + Text("Workspace").frame(maxWidth: .infinity, alignment: .leading) + Text("Lifecycle").frame(width: 150, alignment: .leading) + Text("Build outputs").frame(width: Self.sizeWidth, alignment: .trailing) + Text("node_modules").frame(width: Self.sizeWidth, alignment: .trailing) + Text("Devices").frame(width: Self.sizeWidth, alignment: .trailing) + Text("Total").frame(width: Self.sizeWidth, alignment: .trailing) + Color.clear.frame(width: 28) + } + .font(Theme.body(11, weight: .medium)) + .foregroundStyle(Theme.tertiary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private func workspaceRow(_ workspace: WorkspaceStorage) -> some View { + let names = PathNames(path: workspace.path) + let lifecycle = WorktreeLifecycle( + worktree: workspace.worktree, branch: workspace.branch, pulls: storage.pulls(for: workspace)) + return HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(names.title).lineLimit(1) + Text(workspace.branch ?? names.subtitle).font(Theme.body(11)).foregroundStyle(Theme.secondary).lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + lifecycleChip(lifecycle, worktree: workspace.worktree).frame(width: 150, alignment: .leading) + size(workspace.buildOutputs) + .overlay(alignment: .leading) { + if workspace.buildOutputs != nil { + Image(systemName: workspace.buildOutputsKept == nil ? "trash" : "lock") + .font(.system(size: 9)) + .foregroundStyle(workspace.buildOutputsKept == nil ? Theme.warn : Theme.tertiary) + .help(workspace.buildOutputsKept.map { "Kept by stim gc --delete: \($0)" } ?? "stim gc --delete clears these") + } + } + size(workspace.nodeModules) + size(workspace.devices) + .help(workspace.deviceCount == 0 ? "No owned simulator or emulator" : "\(workspace.deviceCount) owned devices") + Text(workspace.total > 0 ? formatDisk(workspace.total) : "\u{2014}") + .font(Theme.mono(11.5)).fontWeight(.semibold) + .frame(width: Self.sizeWidth, alignment: .trailing) + Menu { + Button("Reveal in Finder") { reveal(workspace.worktreePath) } + Divider() + Button("Remove worktree\u{2026}", role: .destructive) { removing = workspace } + .disabled(actions.active(for: workspace.path) != nil) + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(width: 28) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + @ViewBuilder + private func lifecycleChip(_ lifecycle: WorktreeLifecycle?, worktree: GcReport.LinkedWorktree?) -> some View { + switch lifecycle { + case .merged: + Chip(tint: Theme.live) { Text(lifecycle!.title) }.help(worktree?.detail ?? "") + case .pullRequest(_, let url): + Button { URL(string: url).map { _ = NSWorkspace.shared.open($0) } } label: { + Chip(tint: Theme.remote) { Text(lifecycle!.title) } + } + .buttonStyle(.plain) + .help(url) + case .stale: + Chip(tint: Theme.warn) { Text(lifecycle!.title) }.help("No recorded use for that long") + case .active: + Text("Active").foregroundStyle(Theme.tertiary) + case nil: + Text("\u{2014}").foregroundStyle(Theme.tertiary).help("Not a linked worktree stim gc sweeps") + } + } + + private func size(_ bytes: Int64?) -> some View { + Text(bytes.map(formatDisk) ?? "\u{2014}") + .font(Theme.mono(11.5)) + .foregroundStyle(bytes == nil ? Theme.tertiary : Theme.text) + .frame(width: Self.sizeWidth, alignment: .trailing) + } + + private func removeMerged(_ merged: [GcReport.LinkedWorktree], report: StorageReport) { + let steps = merged.map { worktree in + let repository = report.workspaces.first { $0.worktreePath == worktree.path }?.repository + return StimCommand(["worktree", "remove", worktree.path], cwd: repository ?? NSHomeDirectory()) + } + actions.run("Remove merged worktrees", steps: steps, key: ActionCenter.machineKey) + } + + private func stim(_ report: StorageReport) -> some View { + let outputs = metrics.gcReport?.clearableOutputs ?? [] + return VStack(alignment: .leading, spacing: 10) { + Text("Stim caches and devices").font(Theme.heading(15)) + Card { + VStack(spacing: 0) { + locationRow( + StorageLocation( + title: "Build outputs of idle workspaces", path: nil, + bytes: outputs.compactMap(\.bytes).reduce(0, +), + detail: outputs.count == 1 ? "1 workspace not in use" : "\(outputs.count) workspaces not in use"), + icon: "hammer" + ) { + previewButton("Clear\u{2026}", ["gc", "--json", "--cache", "workspaces"]).disabled(outputs.isEmpty) + } + ForEach(report.caches, id: \.dir) { cache in + Rectangle().fill(Theme.border).frame(height: 1) + locationRow( + StorageLocation(title: cache.name, path: cache.dir, bytes: cache.bytes, detail: cache.note ?? cache.dir), + icon: "shippingbox" + ) { + previewButton("Empty\u{2026}", ["gc", "--json", "--cache", cache.name]) + } + } + if let devices = report.reclaimableDevices { + Rectangle().fill(Theme.border).frame(height: 1) + locationRow(devices, icon: "iphone") { + previewButton("Reclaim\u{2026}", ["gc", "--json"]) + } + } + } + } + } + } + + private func unmanaged(_ report: StorageReport) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Outside Stim").font(Theme.heading(15)) + Text("Information only. Stim never deletes these.").foregroundStyle(Theme.tertiary) + } + if report.unmanaged.isEmpty { + Text(storage.measuring ? "Measuring\u{2026}" : "Not measured yet.").foregroundStyle(Theme.tertiary) + } else { + Card { + VStack(spacing: 0) { + ForEach(Array(report.unmanaged.enumerated()), id: \.element.id) { index, location in + if index > 0 { Rectangle().fill(Theme.border).frame(height: 1) } + locationRow(location, icon: "folder") { + if let path = location.path { Button("Reveal in Finder") { reveal(path) } } + } + } + } + } + } + } + } + + private func locationRow( + _ location: StorageLocation, icon: String, @ViewBuilder action: () -> Action + ) -> some View { + HStack(spacing: 14) { + Image(systemName: icon).foregroundStyle(Theme.lavender).frame(width: 18) + VStack(alignment: .leading, spacing: 3) { + Text(location.title) + if let detail = location.detail { + Text(detail.replacingOccurrences(of: NSHomeDirectory(), with: "~")).font(Theme.body(11.5)) + .foregroundStyle(Theme.secondary).lineLimit(1).truncationMode(.middle) + } + } + Spacer() + size(location.bytes) + action().frame(minWidth: 120, alignment: .trailing) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + @ViewBuilder + private func previewButton(_ title: String, _ arguments: [String]) -> some View { + if let active = actions.active(for: ActionCenter.machineKey) { + Button { + actions.presented = active + } label: { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Running") + } + } + } else { + Button(title) { + actions.run("Preview cleanup", StimCommand(arguments, cwd: NSHomeDirectory()), key: ActionCenter.machineKey) + } + .help("stim \(arguments.joined(separator: " ")), then a confirmation before stim gc --delete") + } + } + + private func reveal(_ path: String) { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } +} diff --git a/apps/desktop/Sources/StimKit/AppPreferences.swift b/apps/desktop/Sources/StimKit/AppPreferences.swift index 94d03a3ee..1a0896f32 100644 --- a/apps/desktop/Sources/StimKit/AppPreferences.swift +++ b/apps/desktop/Sources/StimKit/AppPreferences.swift @@ -15,12 +15,35 @@ public enum AppPreferences { public static let showsMenuBarExtra = "showsMenuBarExtra" public static let stimExecutable = "stimExecutable" public static let remoteSessionMinutes = "remoteSessionMinutes" + public static let autopilotIdleShutdown = "autopilot.idleShutdown" + public static let autopilotIdleMinutes = "autopilot.idleMinutes" + public static let autopilotNightly = "autopilot.nightly" + public static let autopilotNightlyHour = "autopilot.nightlyHour" + public static let autopilotPressure = "autopilot.pressure" + public static let autopilotLastNightly = "autopilot.lastNightly" + public static let autopilotLog = "autopilot.log" + public static let notifiesDiskPressure = "notify.diskPressure" public static func notifies(_ kind: StatusEvent.Kind) -> String { "notify.\(kind.rawValue)" } } public static let frameRates: [Double] = [60, 30, 15, 5] + /// Autopilot is on by default: idle devices shut down after an hour, `stim gc --delete` runs at 3:00, and + /// disk pressure is acted on. + public static var defaults: [String: Any] { + [ + Key.autopilotIdleShutdown: true, + Key.autopilotIdleMinutes: 60, + Key.autopilotNightly: true, + Key.autopilotNightlyHour: 3, + Key.autopilotPressure: true, + Key.notifiesDiskPressure: true, + ] + } + + public static let idleMinuteChoices = [30, 60, 120, 240] + /// The live-frame cap the simulator and emulator views read on every frame. public static var maxFramesPerSecond: Double { let value = UserDefaults.standard.double(forKey: Key.maxFramesPerSecond) diff --git a/apps/desktop/Sources/StimKit/Autopilot.swift b/apps/desktop/Sources/StimKit/Autopilot.swift new file mode 100644 index 000000000..b0cdd4f1d --- /dev/null +++ b/apps/desktop/Sources/StimKit/Autopilot.swift @@ -0,0 +1,149 @@ +import Foundation + +/// When Stim Desktop's autopilot runs its cleanup commands. +public enum AutopilotSchedule { + /// True once the most recent `hour`:00 at or before `now` is later than `lastRun`, so a nightly run + /// the Mac slept through happens at the next check. + public static func nightlyDue(now: Date, hour: Int, lastRun: Date, calendar: Calendar = .current) -> Bool { + guard let today = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: now) else { return false } + let scheduled = today <= now ? today : calendar.date(byAdding: .day, value: -1, to: today) ?? today + return lastRun < scheduled + } + + /// A booted device and when the app last saw its screen change. + public struct Device: Sendable { + public var activity: DeviceActivity? + public var screenChangedAt: Date? + + public init(activity: DeviceActivity?, screenChangedAt: Date?) { + self.activity = activity + self.screenChangedAt = screenChangedAt + } + } + + /// Whether `stim gc --idle m` has a device to shut down. The CLI cannot see screen changes, so + /// when a device it counts as idle that long has a screen the app saw change since, the run waits: + /// `gc --idle` would shut that device down too. + public static func idleShutdownDue(_ devices: [Device], minutes: Int, now: Date) -> Bool { + let threshold = TimeInterval(minutes * 60) + func idleFor(_ badge: ActivityBadge?) -> TimeInterval? { + if case .idle(let seconds) = badge { return seconds } + return nil + } + var due = false + for device in devices { + guard let cli = idleFor(ActivityBadge(device.activity, now: now)), cli >= threshold else { continue } + let seen = idleFor(ActivityBadge(device.activity, screenChangedAt: device.screenChangedAt, now: now)) + guard let seen, seen >= threshold else { return false } + due = true + } + return due + } + + public static func idleDuration(minutes: Int) -> String { "\(minutes)m" } +} + +/// Free disk under the `budget.minFreeDiskGb` Stim enforces, and what `stim gc --delete` would do about it. +public struct PressurePlan: Hashable, Sendable { + public var freeBytes: Int64 + /// The larger of `budget.minFreeDiskGb` and `budget.hardFloorDiskGb`, in the CLI's binary gigabytes. + public var minimumFreeGb: Double + /// Below `budget.hardFloorDiskGb`, where `start`, `ios` and `android` refuse with STIM_LOW_DISK. + public var belowHardFloor: Bool + public var clearsWorkspaces: Int + public var removesWorktrees: Int + public var deletesDevices: Int + public var reclaimableBytes: Int64 + + public var isEmpty: Bool { clearsWorkspaces + removesWorktrees + deletesDevices == 0 && reclaimableBytes == 0 } + + public static let arguments = ["gc", "--delete"] + + /// The plan when free space is under the budget, else nil. A budget of 0 turns the check off, as in the CLI. + public static func make(freeBytes: Int64, minimumFreeGb: Double, hardFloorGb: Double, report: GcReport?) + -> PressurePlan? + { + let minimum = max(minimumFreeGb, hardFloorGb) + guard minimum > 0, Double(freeBytes) < minimum * gib else { return nil } + return PressurePlan( + freeBytes: freeBytes, minimumFreeGb: minimum, belowHardFloor: Double(freeBytes) < hardFloorGb * gib, + clearsWorkspaces: report?.clearableOutputs.count ?? 0, removesWorktrees: report?.mergedWorktrees.count ?? 0, + deletesDevices: report?.deletableDevices.count ?? 0, reclaimableBytes: report?.reclaimable.bytes ?? 0) + } + + static let gib = 1_073_741_824.0 + + public var headline: String { + let free = (Double(freeBytes) / Self.gib * 10).rounded() / 10 + let budget = minimumFreeGb.rounded() == minimumFreeGb ? String(Int(minimumFreeGb)) : String(minimumFreeGb) + return "Disk \(free) GB free, under the \(budget) GB Stim budget" + } + + /// What `stim gc --delete` would do, as one sentence. + public var proposal: String { + guard !isEmpty else { + return "Stim has nothing it can reclaim safely. Open Storage to see what uses the space." + } + var parts: [String] = [] + if clearsWorkspaces > 0 { + parts.append("clear the build outputs of \(Self.count(clearsWorkspaces, "idle workspace"))") + } + if removesWorktrees > 0 { parts.append("remove \(Self.count(removesWorktrees, "merged worktree"))") } + if deletesDevices > 0 { parts.append("delete \(Self.count(deletesDevices, "unused owned device"))") } + if parts.isEmpty { parts.append("empty what stim gc reports") } + let list = parts.count == 1 ? parts[0] : parts.dropLast().joined(separator: ", ") + " and " + parts.last! + let freed = reclaimableBytes > 0 ? " to free about \(Self.format(reclaimableBytes))" : "" + return list.prefix(1).uppercased() + list.dropFirst() + freed + "." + } + + static func count(_ n: Int, _ noun: String) -> String { n == 1 ? "1 \(noun)" : "\(n) \(noun)s" } + + static func format(_ bytes: Int64) -> String { ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) } +} + +/// One autopilot or plan run, kept in the app's activity log. +public struct AutopilotLogEntry: Codable, Hashable, Identifiable, Sendable { + public enum Trigger: String, Codable, Sendable { + case idle, nightly, pressure, manual + + public var title: String { + switch self { + case .idle: return "Idle devices" + case .nightly: return "Nightly cleanup" + case .pressure: return "Disk pressure" + case .manual: return "Requested" + } + } + } + + public var id: UUID + public var date: Date + public var trigger: Trigger + public var command: String + public var exitStatus: Int32? + public var note: String? + + public init(date: Date, trigger: Trigger, command: String, exitStatus: Int32?, note: String?) { + id = UUID() + self.date = date + self.trigger = trigger + self.command = command + self.exitStatus = exitStatus + self.note = note + } +} + +public enum AutopilotLog { + public static let limit = 200 + + /// `entries` with `entry` first, keeping the newest `limit`. + public static func appending(_ entry: AutopilotLogEntry, to entries: [AutopilotLogEntry]) -> [AutopilotLogEntry] { + Array(([entry] + entries).prefix(limit)) + } + + public static func decode(_ data: Data?) -> [AutopilotLogEntry] { + data.flatMap { try? JSONDecoder().decode([AutopilotLogEntry].self, from: $0) } ?? [] + } + + public static func encode(_ entries: [AutopilotLogEntry]) -> Data? { try? JSONEncoder().encode(entries) } +} diff --git a/apps/desktop/Sources/StimKit/GcPreview.swift b/apps/desktop/Sources/StimKit/GcPreview.swift index e5eb11c82..ed4139804 100644 --- a/apps/desktop/Sources/StimKit/GcPreview.swift +++ b/apps/desktop/Sources/StimKit/GcPreview.swift @@ -99,6 +99,15 @@ public struct GcPreview: Sendable { "easSessionSweepNotices", "skipped", "idleDevices", "unverifiedDevices", ] + /// The `stim gc` arguments that act on what a dry run with `preview` reported: the same scope with + /// `--delete`, so a preview of one cache never becomes a full `gc --delete`. Nil for arguments that are + /// not a `gc --json` dry run. + public static func deleteArguments(after preview: [String]) -> [String]? { + guard preview.first == "gc", preview.contains("--json"), !preview.contains("--delete"), !preview.contains("--idle") + else { return nil } + return preview.filter { $0 != "--json" } + ["--delete"] + } + /// Durations offered for `stim gc --idle`. public static let idleDurations = ["30m", "1h", "2h", "4h", "1d"] diff --git a/apps/desktop/Sources/StimKit/Resources.swift b/apps/desktop/Sources/StimKit/Resources.swift index 692c8a8ac..9ff7ae34e 100644 --- a/apps/desktop/Sources/StimKit/Resources.swift +++ b/apps/desktop/Sources/StimKit/Resources.swift @@ -158,13 +158,18 @@ public struct DiskVolume: Equatable, Identifiable, Sendable { public var availableBytes: Int64 public var totalBytes: Int64 public var holds: [String] + /// Free space without purgeable space, which is what Stim's disk budget measures. + public var unpurgeableFreeBytes: Int64? - public init(id: String, name: String, availableBytes: Int64, totalBytes: Int64, holds: [String]) { + public init( + id: String, name: String, availableBytes: Int64, totalBytes: Int64, holds: [String], unpurgeableFreeBytes: Int64? = nil + ) { self.id = id self.name = name self.availableBytes = availableBytes self.totalBytes = totalBytes self.holds = holds + self.unpurgeableFreeBytes = unpurgeableFreeBytes } } @@ -193,6 +198,7 @@ public enum DiskUsage { } let keys: Set = [ .volumeURLKey, .volumeNameKey, .volumeAvailableCapacityForImportantUsageKey, .volumeTotalCapacityKey, + .volumeAvailableCapacityKey, ] guard let values = try? url.resourceValues(forKeys: keys), let volume = values.volume, @@ -201,29 +207,61 @@ public enum DiskUsage { else { return nil } return DiskVolume( id: volume.path, name: values.volumeName ?? volume.lastPathComponent, - availableBytes: available, totalBytes: Int64(total), holds: [label]) + availableBytes: available, totalBytes: Int64(total), holds: [label], + unpurgeableFreeBytes: values.volumeAvailableCapacity.map(Int64.init)) } } -/// What `stim gc --json` (a dry run) says `stim gc --delete` would free. +/// The parts of the `stim gc --json` dry run that size what Stim can reclaim. public struct GcReport: Decodable, Sendable { - struct Sized: Decodable, Sendable { - var bytes: Int64? - var willClear: Bool? - var willEmpty: Bool? + public struct Sized: Decodable, Hashable, Sendable { + public var bytes: Int64? } - struct Sections: Decodable, Sendable { - var orphanedWorkspaces: [Sized]? - var parkedSimulators: [Sized]? - var parkedEmulators: [Sized]? - var orphanedDevices: [Sized]? - var staleDevices: [Sized]? - var workspaceBuildOutputs: [Sized]? - var caches: [Sized]? + public struct Device: Decodable, Hashable, Sendable { + public var udid: String? + public var id: String? + public var name: String? + public var bytes: Int64? } - var sections: Sections + public struct LinkedWorktree: Decodable, Hashable, Sendable { + public var path: String + public var idleDays: Int? + public var mergedInto: String? + public var willRemove: Bool + public var detail: String? + } + + public struct BuildOutputs: Decodable, Hashable, Sendable { + public var dir: String? + public var projectRoot: String? + public var bytes: Int64? + public var idleDays: Int? + public var willClear: Bool? + public var detail: String? + } + + public struct Cache: Decodable, Hashable, Sendable { + public var name: String + public var dir: String + public var bytes: Int64? + public var note: String? + public var willEmpty: Bool? + } + + public struct Sections: Decodable, Sendable { + public var orphanedWorkspaces: [Sized]? + public var linkedWorktrees: [LinkedWorktree]? + public var parkedSimulators: [Device]? + public var parkedEmulators: [Device]? + public var orphanedDevices: [Device]? + public var staleDevices: [Device]? + public var workspaceBuildOutputs: [BuildOutputs]? + public var caches: [Cache]? + } + + public var sections: Sections public struct Reclaimable: Equatable, Sendable { public var bytes: Int64 @@ -232,17 +270,29 @@ public struct GcReport: Decodable, Sendable { public var unsized: Int } - public var reclaimable: Reclaimable { + /// Owned devices `stim gc --delete` deletes. + public var deletableDevices: [Device] { let s = sections - var removed: [Sized] = [] - for group in [s.orphanedWorkspaces, s.parkedSimulators, s.parkedEmulators, s.orphanedDevices, s.staleDevices] { - removed += group ?? [] - } - removed += (s.workspaceBuildOutputs ?? []).filter { $0.willClear == true } - removed += (s.caches ?? []).filter { $0.willEmpty == true } + return [s.parkedSimulators, s.parkedEmulators, s.orphanedDevices, s.staleDevices].flatMap { $0 ?? [] } + } + + /// Build outputs `stim gc --delete` clears because their workspace is not in use. + public var clearableOutputs: [BuildOutputs] { + (sections.workspaceBuildOutputs ?? []).filter { $0.willClear == true } + } + + /// Linked worktrees `stim gc --delete` removes because their branch is merged. + public var mergedWorktrees: [LinkedWorktree] { + (sections.linkedWorktrees ?? []).filter { $0.willRemove && $0.mergedInto != nil } + } + + public var reclaimable: Reclaimable { + let removed = + (sections.orphanedWorkspaces ?? []).map(\.bytes) + deletableDevices.map(\.bytes) + + clearableOutputs.map(\.bytes) + (sections.caches ?? []).filter { $0.willEmpty == true }.map(\.bytes) return Reclaimable( - bytes: removed.reduce(0) { $0 + ($1.bytes ?? 0) }, + bytes: removed.reduce(0) { $0 + ($1 ?? 0) }, entries: removed.count, - unsized: removed.filter { $0.bytes == nil }.count) + unsized: removed.filter { $0 == nil }.count) } } diff --git a/apps/desktop/Sources/StimKit/SettingsState.swift b/apps/desktop/Sources/StimKit/SettingsState.swift index dabdd935f..e7befcce1 100644 --- a/apps/desktop/Sources/StimKit/SettingsState.swift +++ b/apps/desktop/Sources/StimKit/SettingsState.swift @@ -30,6 +30,10 @@ public struct SettingEntry: Decodable, Hashable, Sendable { public func layer(_ scope: SettingScope) -> JSONValue? { layers[scope.rawValue] } + /// The effective number. `stim settings --json` reports a value set by an environment variable as the + /// variable's string, so a numeric string counts. + public var number: Double? { value.number ?? value.string.flatMap { Double($0.trimmingCharacters(in: .whitespaces)) } } + /// The layer a value in `scope` would override: the next lower layer that /// holds a value, or the default. public func overridden(by scope: SettingScope, field: SettingField) -> (source: String, value: JSONValue)? { diff --git a/apps/desktop/Sources/StimKit/Status.swift b/apps/desktop/Sources/StimKit/Status.swift index 5ce9892e1..3f7df2804 100644 --- a/apps/desktop/Sources/StimKit/Status.swift +++ b/apps/desktop/Sources/StimKit/Status.swift @@ -34,6 +34,7 @@ public struct Workspace: Decodable, Identifiable, Hashable, Sendable { public var slots: [Slot]? public var remoteDevices: [RemoteDevice]? public var build: Build? + public var worktree: WorktreeInfo? public var id: String { path } @@ -61,6 +62,13 @@ public struct Workspace: Decodable, Identifiable, Hashable, Sendable { public var names: PathNames { PathNames(path: path) } } +/// The git worktree that holds a workspace. +public struct WorktreeInfo: Decodable, Hashable, Sendable { + public var path: String + public var branch: String? + public var repository: String? +} + public struct Slot: Decodable, Hashable, Sendable { public var slot: String public var ios: IosDevice? diff --git a/apps/desktop/Sources/StimKit/Storage.swift b/apps/desktop/Sources/StimKit/Storage.swift new file mode 100644 index 000000000..f72809e39 --- /dev/null +++ b/apps/desktop/Sources/StimKit/Storage.swift @@ -0,0 +1,222 @@ +import Foundation + +/// Where the space Stim Desktop measures lives. Stim's own directories are sized by `stim gc --json`; +/// these are the paths the app sizes itself with `du`, none of them under `$STIM_HOME`. +public struct StoragePaths: Sendable { + public var home: String + public var simulatorDevices: String + public var avds: String + public var derivedData: String + public var gradleCaches: String + public var libraryCaches: String + + /// The AVD directory follows the Android tools: `ANDROID_AVD_HOME`, then `ANDROID_USER_HOME/avd`, then `~/.android/avd`. + public init(home: String, environment: [String: String] = [:]) { + self.home = home + simulatorDevices = "\(home)/Library/Developer/CoreSimulator/Devices" + avds = + environment["ANDROID_AVD_HOME"] ?? environment["ANDROID_USER_HOME"].map { "\($0)/avd" } ?? "\(home)/.android/avd" + derivedData = "\(home)/Library/Developer/Xcode/DerivedData" + gradleCaches = "\(home)/.gradle/caches" + libraryCaches = "\(home)/Library/Caches" + } + + public func simulator(_ udid: String) -> String { "\(simulatorDevices)/\(udid)" } + public func avd(_ name: String) -> String { "\(avds)/\(name).avd" } + + /// Directories `du -d 1` sizes entry by entry, so each device can be attributed. + public var deviceSets: [String] { [simulatorDevices, avds] } + + public var unmanaged: [(title: String, path: String)] { + [ + ("Xcode DerivedData", derivedData), + ("Gradle caches", gradleCaches), + ("~/Library/Caches", libraryCaches), + ] + } +} + +public enum DiskSizes { + /// Parses `du -k` output, one `\t` line per entry. `du` still prints what it could read when it + /// reports an unreadable entry, so partial output is kept. + public static func parse(_ output: String) -> [String: Int64] { + var sizes: [String: Int64] = [:] + for line in output.split(separator: "\n") { + guard let tab = line.firstIndex(of: "\t"), let kib = Int64(line[.. StorageReport { + let outputs = Dictionary( + (gc?.sections.workspaceBuildOutputs ?? []).compactMap { entry in entry.projectRoot.map { ($0, entry) } }, + uniquingKeysWith: { first, _ in first }) + let worktrees = Dictionary( + (gc?.sections.linkedWorktrees ?? []).map { ($0.path, $0) }, uniquingKeysWith: { first, _ in first }) + var stimSimulators = Set((gc?.deletableDevices ?? []).compactMap(\.udid).map { $0.uppercased() }) + + let workspaces = environments.map { env -> WorkspaceStorage in + let root = env.worktree?.path ?? env.path + var deviceBytes: Int64? + var count = 0 + for device in env.devices { + let path: String + switch device { + case .ios(_, let sim) where sim.owned: + stimSimulators.insert(sim.udid.uppercased()) + path = paths.simulator(sim.udid.uppercased()) + case .android(_, let avd) where avd.owned && !avd.physical: + path = paths.avd(avd.name) + default: + continue + } + count += 1 + if let bytes = sizes[path] { deviceBytes = (deviceBytes ?? 0) + bytes } + } + let output = outputs[env.path] + return WorkspaceStorage( + path: env.path, worktreePath: root, repository: env.worktree?.repository, branch: env.worktree?.branch, + buildOutputs: output?.bytes, + buildOutputsKept: output.flatMap { $0.willClear == true ? nil : ($0.detail ?? "kept") }, + nodeModules: sizes["\(root)/node_modules"], devices: deviceBytes, deviceCount: count, + worktree: worktrees[root]) + } + .sorted { ($0.total, $1.path) > ($1.total, $0.path) } + + let devices = gc?.deletableDevices ?? [] + let reclaimableDevices = + devices.isEmpty + ? nil + : StorageLocation( + title: devices.count == 1 ? "1 parked, orphaned or stale device" : "\(devices.count) parked, orphaned or stale devices", + path: nil, bytes: devices.contains { $0.bytes != nil } ? devices.compactMap(\.bytes).reduce(0, +) : nil, + detail: devices.compactMap(\.name).joined(separator: ", ")) + + let caches = gc?.sections.caches ?? [] + var unmanaged: [StorageLocation] = [] + let simulatorEntries = sizes.filter { path, _ in + (path as NSString).deletingLastPathComponent == paths.simulatorDevices + && UUID(uuidString: (path as NSString).lastPathComponent) != nil + } + if !simulatorEntries.isEmpty { + let other = simulatorEntries.filter { !stimSimulators.contains(($0.key as NSString).lastPathComponent.uppercased()) } + unmanaged.append( + StorageLocation( + title: "Simulators Stim does not own", path: paths.simulatorDevices, bytes: other.values.reduce(0, +), + detail: "\(other.count) of \(simulatorEntries.count) CoreSimulator devices")) + } + for location in paths.unmanaged { + guard let measured = sizes[location.path] else { continue } + let inside = caches.filter { $0.dir.hasPrefix(location.path + "/") } + let stimBytes = inside.compactMap(\.bytes).reduce(0, +) + unmanaged.append( + StorageLocation( + title: location.title, path: location.path, bytes: max(0, measured - stimBytes), + detail: inside.isEmpty ? nil : "Excludes \(inside.map(\.name).joined(separator: ", ")), listed under Stim")) + } + return StorageReport( + workspaces: workspaces, caches: caches, reclaimableDevices: reclaimableDevices, unmanaged: unmanaged) + } +} + +/// Where a linked worktree is in its life, for the Storage view's lifecycle column. +public enum WorktreeLifecycle: Hashable, Sendable { + case merged(into: String) + case pullRequest(number: Int, url: String) + case stale(days: Int) + case active + + /// Days without recorded use after which a worktree with no open pull request reads as stale. + public static let staleDays = 7 + + /// `worktree` is the `stim gc --json` entry, nil for a checkout gc does not sweep. `pulls` maps branch + /// names to open pull requests, nil when the GitHub CLI could not answer. + public init?(worktree: GcReport.LinkedWorktree?, branch: String?, pulls: [String: PullRequest]?) { + if let into = worktree?.mergedInto { + self = .merged(into: into) + } else if let branch, let pull = pulls?[branch] { + self = .pullRequest(number: pull.number, url: pull.url) + } else if let worktree { + if let days = worktree.idleDays, days >= Self.staleDays { + self = .stale(days: days) + } else { + self = .active + } + } else { + return nil + } + } + + public var title: String { + switch self { + case .merged(let into): return "Merged into \(into.replacingOccurrences(of: "origin/", with: ""))" + case .pullRequest(let number, _): return "PR #\(number) open" + case .stale(let days): return "Stale \(days)d" + case .active: return "Active" + } + } +} + +public struct PullRequest: Decodable, Hashable, Sendable { + public var number: Int + public var url: String + public var headRefName: String + + /// `gh pr list` arguments for the open pull requests of the repository in the working directory. + public static let listArguments = ["pr", "list", "--state", "open", "--json", "number,url,headRefName", "--limit", "200"] + + /// Open pull requests by head branch, or nil when the output is not a `gh pr list --json` array. + public static func byBranch(_ json: Data) -> [String: PullRequest]? { + guard let pulls = try? JSONDecoder().decode([PullRequest].self, from: json) else { return nil } + return Dictionary(pulls.map { ($0.headRefName, $0) }, uniquingKeysWith: { first, _ in first }) + } +} diff --git a/apps/desktop/Tests/StimKitTests/StorageTests.swift b/apps/desktop/Tests/StimKitTests/StorageTests.swift new file mode 100644 index 000000000..076390f3b --- /dev/null +++ b/apps/desktop/Tests/StimKitTests/StorageTests.swift @@ -0,0 +1,201 @@ +import Foundation +import Testing + +@testable import StimKit + +@Suite struct StorageReportTests { + let paths = StoragePaths(home: "/Users/me") + let owned = "11111111-1111-1111-1111-111111111111" + let parked = "22222222-2222-2222-2222-222222222222" + let users = "33333333-3333-3333-3333-333333333333" + + func workspace() throws -> Workspace { + let json = """ + {"path":"/r/.worktrees/a/app","live":true,"warnings":[], + "worktree":{"path":"/r/.worktrees/a","branch":"feat-a","repository":"/r"}, + "ios":{"name":"stim-a","udid":"\(owned.lowercased())","owned":true,"state":"Booted"}, + "android":{"name":"Pixel","owned":false,"physical":false,"state":"device"}, + "slots":[{"slot":"tablet","android":{"name":"stim-a-tablet","owned":true,"physical":false,"state":"device"}}]} + """ + return try JSONDecoder().decode(Workspace.self, from: Data(json.utf8)) + } + + func gc() throws -> GcReport { + let json = """ + {"sections":{ + "linkedWorktrees":[{"path":"/r/.worktrees/a","idleDays":2,"mergedInto":"origin/main","willRemove":true}], + "parkedSimulators":[{"udid":"\(parked)","name":"stim-parked","bytes":3072}], + "workspaceBuildOutputs":[{"dir":"/h/w/a","projectRoot":"/r/.worktrees/a/app","bytes":8192,"willClear":false, + "detail":"in use: dev server running"}], + "caches":[{"name":"Gradle build cache","dir":"/Users/me/.gradle/caches/build-cache-1","bytes":2048}]}} + """ + return try JSONDecoder().decode(GcReport.self, from: Data(json.utf8)) + } + + /// Catches double counting: a Stim cache inside an unmanaged location, or a Stim simulator counted as the user's. + @Test func attributesSpaceToTheWorkspaceAndKeepsStimOutOfUnmanagedTotals() throws { + let du = """ + 4\t/r/.worktrees/a/node_modules + 10\t/Users/me/Library/Developer/CoreSimulator/Devices/\(owned) + 3\t/Users/me/Library/Developer/CoreSimulator/Devices/\(parked) + 7\t/Users/me/Library/Developer/CoreSimulator/Devices/\(users) + 20\t/Users/me/Library/Developer/CoreSimulator/Devices + 5\t/Users/me/.android/avd/stim-a-tablet.avd + 9\t/Users/me/.android/avd/Pixel.avd + 6\t/Users/me/.gradle/caches + du: /Users/me/Library/Caches/locked: Permission denied + 1\t/Users/me/Library/Caches + """ + let report = StorageReport.make( + environments: [try workspace()], gc: try gc(), sizes: DiskSizes.parse(du), paths: paths) + + let row = try #require(report.workspaces.first) + #expect(row.buildOutputs == 8192) + #expect(row.buildOutputsKept == "in use: dev server running") + #expect(row.nodeModules == 4096) + #expect(row.devices == Int64((10 + 5) * 1024)) + #expect(row.deviceCount == 2) + #expect(row.worktree?.mergedInto == "origin/main") + + #expect(report.reclaimableDevices?.bytes == 3072) + let unmanaged = Dictionary(report.unmanaged.map { ($0.title, $0.bytes) }, uniquingKeysWith: { a, _ in a }) + #expect(unmanaged["Simulators Stim does not own"] == 7 * 1024) + #expect(unmanaged["Gradle caches"] == 6 * 1024 - 2048) + #expect(unmanaged["~/Library/Caches"] == 1024) + #expect(unmanaged["Xcode DerivedData"] == nil) + } + + @Test func leavesUnmeasuredCategoriesUnknown() throws { + let report = StorageReport.make(environments: [try workspace()], gc: nil, sizes: [:], paths: paths) + let row = try #require(report.workspaces.first) + #expect(row.buildOutputs == nil && row.nodeModules == nil && row.devices == nil) + #expect(report.unmanaged.isEmpty) + } +} + +@Suite struct WorktreeLifecycleTests { + func worktree(idle: Int?, merged: String? = nil) -> GcReport.LinkedWorktree { + GcReport.LinkedWorktree(path: "/w", idleDays: idle, mergedInto: merged, willRemove: merged != nil, detail: nil) + } + + let pulls = ["feat": PullRequest(number: 42, url: "https://github.com/o/r/pull/42", headRefName: "feat")] + + @Test func mergedWinsOverAnOpenPullRequest() { + #expect( + WorktreeLifecycle(worktree: worktree(idle: 1, merged: "origin/main"), branch: "feat", pulls: pulls) + == .merged(into: "origin/main")) + } + + @Test func anOpenPullRequestKeepsAnIdleWorktreeFromReadingAsStale() { + #expect( + WorktreeLifecycle(worktree: worktree(idle: 30), branch: "feat", pulls: pulls) + == .pullRequest(number: 42, url: "https://github.com/o/r/pull/42")) + } + + @Test func withoutTheGitHubCLIIdlenessAloneDecides() { + #expect(WorktreeLifecycle(worktree: worktree(idle: 7), branch: "feat", pulls: nil) == .stale(days: 7)) + #expect(WorktreeLifecycle(worktree: worktree(idle: 6), branch: "feat", pulls: nil) == .active) + #expect(WorktreeLifecycle(worktree: worktree(idle: nil), branch: "feat", pulls: nil) == .active) + #expect(WorktreeLifecycle(worktree: nil, branch: "main", pulls: nil) == nil) + } + + @Test func readsGhPullRequestsByBranchAndRejectsAnythingElse() { + let json = #"[{"number":7,"url":"https://github.com/o/r/pull/7","headRefName":"fix"}]"# + #expect(PullRequest.byBranch(Data(json.utf8))?["fix"]?.number == 7) + #expect(PullRequest.byBranch(Data("no git remotes found".utf8)) == nil) + } +} + +@Suite struct AutopilotTests { + let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Montreal")! + return calendar + }() + + func date(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Date { + calendar.date(from: DateComponents(year: 2026, month: 9, day: day, hour: hour, minute: minute))! + } + + @Test func runsNightlyOnceAfterTheHourAndCatchesUpAMissedNight() { + #expect(!AutopilotSchedule.nightlyDue(now: date(25, 2, 59), hour: 3, lastRun: date(24, 3, 1), calendar: calendar)) + #expect(AutopilotSchedule.nightlyDue(now: date(25, 3), hour: 3, lastRun: date(24, 3, 1), calendar: calendar)) + #expect(!AutopilotSchedule.nightlyDue(now: date(25, 14), hour: 3, lastRun: date(25, 3, 1), calendar: calendar)) + #expect(AutopilotSchedule.nightlyDue(now: date(25, 1), hour: 3, lastRun: date(23, 3, 1), calendar: calendar)) + } + + func device(idleSince: Date, screen: Date? = nil) -> AutopilotSchedule.Device { + let stamp = ISO8601DateFormatter().string(from: idleSince) + return AutopilotSchedule.Device( + activity: DeviceActivity(state: "idle", driver: nil, lastActivityAt: stamp, basis: []), screenChangedAt: screen) + } + + @Test func shutsDownIdleDevicesOnlyWhenNoWatchedScreenWouldGoWithThem() { + let now = date(25, 12) + let idle = device(idleSince: now.addingTimeInterval(-7200)) + let watched = device(idleSince: now.addingTimeInterval(-7200), screen: now.addingTimeInterval(-60)) + let recent = device(idleSince: now.addingTimeInterval(-600)) + #expect(AutopilotSchedule.idleShutdownDue([idle, recent], minutes: 60, now: now)) + #expect(!AutopilotSchedule.idleShutdownDue([idle, watched], minutes: 60, now: now)) + #expect(!AutopilotSchedule.idleShutdownDue([recent], minutes: 60, now: now)) + } + + @Test func proposesGcDeleteOnlyUnderTheBudget() throws { + let gib: Int64 = 1_073_741_824 + let json = """ + {"sections":{ + "linkedWorktrees":[{"path":"/w","mergedInto":"origin/main","willRemove":true},{"path":"/x","willRemove":false}], + "parkedSimulators":[{"udid":"U","bytes":1000000000}], + "workspaceBuildOutputs":[{"projectRoot":"/a","bytes":25000000000,"willClear":true}, + {"projectRoot":"/b","bytes":9,"willClear":true},{"projectRoot":"/c","bytes":5,"willClear":false}]}} + """ + let report = try JSONDecoder().decode(GcReport.self, from: Data(json.utf8)) + #expect(PressurePlan.make(freeBytes: 25 * gib, minimumFreeGb: 20, hardFloorGb: 5, report: report) == nil) + #expect(PressurePlan.make(freeBytes: gib, minimumFreeGb: 0, hardFloorGb: 0, report: report) == nil) + + let plan = try #require(PressurePlan.make(freeBytes: 12 * gib, minimumFreeGb: 20, hardFloorGb: 5, report: report)) + #expect(!plan.belowHardFloor) + #expect(plan.headline == "Disk 12.0 GB free, under the 20 GB Stim budget") + #expect( + plan.proposal + == "Clear the build outputs of 2 idle workspaces, remove 1 merged worktree and delete 1 unused owned device to free about 26 GB." + ) + + let bare = try #require(PressurePlan.make(freeBytes: 4 * gib, minimumFreeGb: 20, hardFloorGb: 5, report: nil)) + #expect(bare.isEmpty && bare.belowHardFloor) + } + + @Test func keepsTheNewestLogEntriesFirst() { + var log: [AutopilotLogEntry] = [] + for index in 0..<(AutopilotLog.limit + 5) { + log = AutopilotLog.appending( + AutopilotLogEntry(date: Date(), trigger: .idle, command: "\(index)", exitStatus: 0, note: nil), to: log) + } + #expect(log.count == AutopilotLog.limit) + #expect(log.first?.command == "\(AutopilotLog.limit + 4)") + #expect(AutopilotLog.decode(AutopilotLog.encode(log)) == log) + } +} + +@Suite struct GcDeleteScopeTests { + @Test func aScopedPreviewDeletesOnlyThatScope() { + #expect(GcPreview.deleteArguments(after: ["gc", "--json"]) == ["gc", "--delete"]) + #expect( + GcPreview.deleteArguments(after: ["gc", "--json", "--cache", "Xcode CAS"]) == ["gc", "--cache", "Xcode CAS", "--delete"]) + #expect(GcPreview.deleteArguments(after: ["gc", "--idle", "1h"]) == nil) + #expect(GcPreview.deleteArguments(after: ["gc", "--delete"]) == nil) + } +} + +@Suite struct BudgetSettingTests { + @Test func readsABudgetSetByAnEnvironmentVariable() throws { + let json = """ + {"files":{},"unknown":[],"settings":[ + {"key":"budget.minFreeDiskGb","value":"40","origin":"env","layers":{}}, + {"key":"budget.hardFloorDiskGb","value":5,"origin":"default","layers":{}}]} + """ + let payload = try JSONDecoder().decode(SettingsPayload.self, from: Data(json.utf8)) + #expect(payload.entry("budget.minFreeDiskGb")?.number == 40) + #expect(payload.entry("budget.hardFloorDiskGb")?.number == 5) + } +} From f724fa1c4f3c48b6b9add7d835848263e786490e Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 25 Sep 2026 02:28:48 -0400 Subject: [PATCH 2/5] fix(desktop): guard autopilot and notification cleanup against stale state --- apps/desktop/README.md | 13 ++++-- .../Sources/StimDesktop/AutopilotRunner.swift | 46 +++++++++++++------ .../Sources/StimDesktop/MetricsStore.swift | 2 - .../Sources/StimDesktop/Notifier.swift | 9 ++++ .../Sources/StimDesktop/StimDesktopApp.swift | 17 ++++--- .../Views/AppPreferencesView.swift | 2 +- .../StimDesktop/Views/StorageView.swift | 24 +++++++--- apps/desktop/Sources/StimKit/Resources.swift | 8 ++++ .../Tests/StimKitTests/StorageTests.swift | 9 ++++ 9 files changed, 99 insertions(+), 31 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 9ec70d31e..5709cc07c 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -100,8 +100,12 @@ status under **Autopilot activity**. that long. It waits while a device the CLI counts as idle has a screen the app saw change more recently, because `gc --idle` would shut that device down too. - **Clean up every night** runs `stim gc --delete` at the chosen hour (3:00 by - default), or at the next check when the Mac slept through it. The first launch - only records the time, so installing the app never starts a cleanup. + default), or at the next check when the Mac slept through it. That clears the + build outputs of every workspace not in use, so its next build installs from + the shared build cache, removes merged worktrees, and deletes parked and + unused owned devices, which empties the parked device pool. The first launch, + turning the option on and changing the hour only record the time, so none of + them starts a cleanup. - **Reclaim space when free disk is under the Stim budget** compares the free space on the volumes Stim writes to, without purgeable space, with `budget.minFreeDiskGb` and `budget.hardFloorDiskGb` from `stim settings --json`. @@ -111,7 +115,10 @@ status under **Autopilot activity**. While free disk is under the budget, the Storage view shows the plan, such as "Clear the build outputs of 3 idle workspaces and remove 1 merged worktree to free about 300 MB", with a **Do it** button that runs `stim gc --delete`, and -the sidebar marks Storage. With autopilot reclaiming, the app posts a +the sidebar marks Storage. **Do it** in a notification runs only while disk +is still under the budget, and otherwise opens Storage; the app removes its +delivered pressure notifications once free disk is back above the budget. With +autopilot reclaiming, the app posts a notification after each run. Without it, the app posts the plan once per episode with a **Do it** button. The **Free disk falls under the Stim budget** notification is on by default and needs the bundled app. diff --git a/apps/desktop/Sources/StimDesktop/AutopilotRunner.swift b/apps/desktop/Sources/StimDesktop/AutopilotRunner.swift index f22550139..87be02974 100644 --- a/apps/desktop/Sources/StimDesktop/AutopilotRunner.swift +++ b/apps/desktop/Sources/StimDesktop/AutopilotRunner.swift @@ -31,6 +31,7 @@ final class AutopilotRunner: ObservableObject { private var report: GcReport? private var reportAt: Date? private var notifiedEpisode = false + private var nightlyHour: Int? init(status: StatusStore, actions: ActionCenter, cli: Task) { self.status = status @@ -46,10 +47,7 @@ final class AutopilotRunner: ObservableObject { if defaults.object(forKey: AppPreferences.Key.autopilotLastNightly) == nil { defaults.set(Date(), forKey: AppPreferences.Key.autopilotLastNightly) } - NotificationResponder.shared.runPlan = { [weak self] in - NSApp.activate(ignoringOtherApps: true) - self?.runPressurePlan(trigger: .manual, present: true) - } + NotificationResponder.shared.runPlan = { [weak self] in self?.runPlanFromNotification() } if defaults.bool(forKey: AppPreferences.Key.notifiesDiskPressure) { Notifier.requestAuthorization() } timer = Timer.scheduledTimer(withTimeInterval: Self.tick, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.check() } @@ -64,14 +62,24 @@ final class AutopilotRunner: ObservableObject { defaults.removeObject(forKey: AppPreferences.Key.autopilotLog) } - /// Runs `stim gc --delete`, which is what the pressure plan proposes. + /// The Do it of a pressure notification, which can be clicked long after it was posted: it runs the plan + /// only while disk is still under the budget, and otherwise opens Storage. + func runPlanFromNotification() { + NSApp.activate(ignoringOtherApps: true) + guard let plan = pressure, !plan.isEmpty else { + OpenRequests.shared.showsStorage = true + return + } + runPressurePlan(trigger: .manual, present: true) + } + func runPressurePlan(trigger: AutopilotLogEntry.Trigger, present: Bool) { lastPressureRun = Date() - run(trigger, "Reclaim disk space", PressurePlan.arguments, present: present) { [weak self] run in - guard trigger == .pressure, let self, let plan = self.pressure else { return } + run(trigger, "Reclaim disk space", PressurePlan.arguments, present: present) { run in + guard trigger == .pressure else { return } Notifier.postPressure( - id: "pressure-ran-\(run.id)", title: "Stim Desktop ran stim gc --delete", - body: "\(plan.headline). \(run.exitStatus == 0 ? "It finished" : "It exited with an error"); see the autopilot log.", + id: "pressure-ran-\(run.id)", title: "Free disk is under the Stim budget", + body: "Stim Desktop ran stim gc --delete. \(run.exitStatus == 0 ? "It finished" : "It exited with an error"); see the autopilot log.", offersPlan: false) } } @@ -79,12 +87,17 @@ final class AutopilotRunner: ObservableObject { private func check() { let now = Date() let idle = actions.active(for: ActionCenter.machineKey) == nil + let hour = defaults.integer(forKey: AppPreferences.Key.autopilotNightlyHour) + if !defaults.bool(forKey: AppPreferences.Key.autopilotNightly) || (nightlyHour != nil && nightlyHour != hour) { + defaults.set(now, forKey: AppPreferences.Key.autopilotLastNightly) + } + nightlyHour = hour if idle, defaults.bool(forKey: AppPreferences.Key.autopilotNightly), let last = defaults.object(forKey: AppPreferences.Key.autopilotLastNightly) as? Date, - AutopilotSchedule.nightlyDue( - now: now, hour: defaults.integer(forKey: AppPreferences.Key.autopilotNightlyHour), lastRun: last) + AutopilotSchedule.nightlyDue(now: now, hour: hour, lastRun: last) { defaults.set(now, forKey: AppPreferences.Key.autopilotLastNightly) + lastPressureRun = now run(.nightly, "Nightly cleanup", ["gc", "--delete"], present: false) return } @@ -100,8 +113,12 @@ final class AutopilotRunner: ObservableObject { checkPressure() } + /// Booted devices with no build running for them, which `gc --idle` also skips. private var bootedDevices: [AutopilotSchedule.Device] { - (status.payload?.environments ?? []).flatMap(\.devices).filter(\.isRunning).map { + (status.payload?.environments ?? []).flatMap { env in + env.devices.filter { $0.isRunning && env.runningBuild(for: $0) == nil } + } + .map { AutopilotSchedule.Device(activity: $0.activity, screenChangedAt: $0.activityKey.flatMap(ScreenActivity.shared.lastChange)) } } @@ -132,7 +149,8 @@ final class AutopilotRunner: ObservableObject { } var fresh = report if plan != nil, fresh == nil { fresh = try? cli.gcReport() } - if let free, let limits, plan != nil { + let known = free != nil && limits != nil && (plan == nil || fresh != nil) + if let free, let limits, plan != nil, fresh != nil { plan = PressurePlan.make(freeBytes: free, minimumFreeGb: limits.minFree, hardFloorGb: limits.hardFloor, report: fresh) } let result = plan @@ -148,6 +166,7 @@ final class AutopilotRunner: ObservableObject { self.report = checkedReport self.reportAt = now } + guard known else { return } self.pressure = result self.react(to: result) } @@ -156,6 +175,7 @@ final class AutopilotRunner: ObservableObject { private func react(to plan: PressurePlan?) { guard let plan else { + if notifiedEpisode { Notifier.removeDeliveredPressure() } notifiedEpisode = false return } diff --git a/apps/desktop/Sources/StimDesktop/MetricsStore.swift b/apps/desktop/Sources/StimDesktop/MetricsStore.swift index c0bcd9775..56f9c75fc 100644 --- a/apps/desktop/Sources/StimDesktop/MetricsStore.swift +++ b/apps/desktop/Sources/StimDesktop/MetricsStore.swift @@ -95,7 +95,6 @@ final class MetricsStore: ObservableObject { } } - /// Runs the `stim gc --json` dry run again unless one is running. func refreshGc() { guard !gcRunning else { return } gcRunning = true @@ -111,7 +110,6 @@ final class MetricsStore: ObservableObject { } } -/// The volumes Stim writes to: the repositories, `$STIM_HOME`, and the CoreSimulator device set. @MainActor func stimDiskLocations(_ workspaces: [Workspace], status: StatusStore) -> [(label: String, path: String)] { let home = NSHomeDirectory() diff --git a/apps/desktop/Sources/StimDesktop/Notifier.swift b/apps/desktop/Sources/StimDesktop/Notifier.swift index b34abace2..5b97539f5 100644 --- a/apps/desktop/Sources/StimDesktop/Notifier.swift +++ b/apps/desktop/Sources/StimDesktop/Notifier.swift @@ -30,6 +30,15 @@ final class Notifier: ObservableObject { static let pressureCategory = "diskPressure" static let doItAction = "doIt" + static func removeDeliveredPressure() { + guard isAvailable else { return } + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { delivered in + center.removeDeliveredNotifications( + withIdentifiers: delivered.map(\.request.identifier).filter { $0.hasPrefix("pressure") }) + } + } + /// Posts a disk pressure notification. With `offersPlan`, it carries a Do it button that runs the plan. static func postPressure(id: String, title: String, body: String, offersPlan: Bool) { guard isAvailable, UserDefaults.standard.bool(forKey: AppPreferences.Key.notifiesDiskPressure) else { return } diff --git a/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift b/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift index df99052e3..18f865c95 100644 --- a/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift +++ b/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift @@ -19,12 +19,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { MainActor.assumeIsolated { OpenRequests.shared.simulatorUdid = udid } } + func applicationWillFinishLaunching(_ notification: Notification) { + MainActor.assumeIsolated { NotificationResponder.shared.install() } + } + func applicationDidFinishLaunching(_ notification: Notification) { // `swift run` starts a bare executable as a background process with no Dock icon or focus. NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) MainActor.assumeIsolated { - NotificationResponder.shared.install() Theme.apply(Appearance(rawValue: UserDefaults.standard.string(forKey: AppPreferences.Key.appearance) ?? "") ?? .auto) } } @@ -58,17 +61,19 @@ struct StimDesktopApp: App { _notifier = StateObject(wrappedValue: Notifier(store: store)) let actions = ActionCenter(cli: cli) _actions = StateObject(wrappedValue: actions) - _autopilot = StateObject(wrappedValue: AutopilotRunner(status: store, actions: actions, cli: cli)) + let autopilot = AutopilotRunner(status: store, actions: actions, cli: cli) + _autopilot = StateObject(wrappedValue: autopilot) + DispatchQueue.main.async { + store.start() + autopilot.start() + } } var body: some Scene { WindowGroup("Stim", id: "main") { RootView(cli: cli, store: store, actions: actions, autopilot: autopilot) .frame(minWidth: 1100, minHeight: 720) - .onAppear { - notifier.start() - autopilot.start() - } + .onAppear { notifier.start() } } .windowToolbarStyle(.unified(showsTitle: false)) .handlesExternalEvents(matching: []) diff --git a/apps/desktop/Sources/StimDesktop/Views/AppPreferencesView.swift b/apps/desktop/Sources/StimDesktop/Views/AppPreferencesView.swift index 49c3af125..af716546b 100644 --- a/apps/desktop/Sources/StimDesktop/Views/AppPreferencesView.swift +++ b/apps/desktop/Sources/StimDesktop/Views/AppPreferencesView.swift @@ -75,7 +75,7 @@ struct AppPreferencesView: View { Text("Autopilot") } footer: { Text( - "Idle shutdown runs stim gc --idle, which shuts owned simulators and emulators down and never deletes them. A device whose screen changed in this app is left running. Nightly cleanup and disk pressure run stim gc --delete, which removes only what stim gc reports as safe: build outputs of workspaces not in use, merged worktrees, and unused owned devices. The budget is budget.minFreeDiskGb. A nightly run the Mac slept through runs at the next check." + "Idle shutdown runs stim gc --idle, which shuts owned simulators and emulators down and never deletes them. A device whose screen changed in this app is left running. Nightly cleanup and disk pressure run stim gc --delete: it clears the build outputs of every workspace not in use, so their next build installs from the shared cache, removes merged worktrees, and deletes parked and unused owned devices. The budget is budget.minFreeDiskGb. A nightly run the Mac slept through runs at the next check." ) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apps/desktop/Sources/StimDesktop/Views/StorageView.swift b/apps/desktop/Sources/StimDesktop/Views/StorageView.swift index 965c2dbf2..a585e4ebd 100644 --- a/apps/desktop/Sources/StimDesktop/Views/StorageView.swift +++ b/apps/desktop/Sources/StimDesktop/Views/StorageView.swift @@ -9,6 +9,7 @@ struct StorageView: View { @ObservedObject var autopilot: AutopilotRunner @EnvironmentObject private var actions: ActionCenter @State private var confirmingMerged = false + @State private var awaitingMerged = false @State private var removing: WorkspaceStorage? private static let sizeWidth: CGFloat = 92 @@ -33,6 +34,11 @@ struct StorageView: View { storage.refresh() if metrics.gcReport == nil { metrics.refreshGc() } } + .onChange(of: metrics.gcRunning) { _, running in + guard !running, awaitingMerged else { return } + awaitingMerged = false + confirmingMerged = !(metrics.gcReport?.mergedWorktrees.isEmpty ?? true) + } } private var header: some View { @@ -89,7 +95,9 @@ struct StorageView: View { let workspaceBytes = report.workspaces.reduce(Int64(0)) { $0 + $1.total } let cacheBytes = report.caches.compactMap(\.bytes).reduce(0, +) return HStack(spacing: 14) { - tile("Free", lowest.map { formatDisk($0.availableBytes) } ?? "\u{2014}", detail: lowest?.name) + tile( + "Free", lowest.map { formatDisk($0.unpurgeableFreeBytes ?? $0.availableBytes) } ?? "\u{2014}", + detail: lowest.map { "\($0.name), without purgeable space" }) tile("Workspaces", formatDisk(workspaceBytes), detail: "\(report.workspaces.count) environments") tile("Stim caches", formatDisk(cacheBytes), detail: "\(report.caches.count) shared caches") VStack(alignment: .leading, spacing: 8) { @@ -129,8 +137,11 @@ struct StorageView: View { Text("Install the GitHub CLI (gh) to show open pull requests.").font(Theme.body(11.5)) .foregroundStyle(Theme.tertiary) } - Button("Remove merged worktrees (\(merged.count))\u{2026}") { confirmingMerged = true } - .disabled(merged.isEmpty || actions.active(for: ActionCenter.machineKey) != nil) + Button("Remove merged worktrees (\(merged.count))\u{2026}") { + awaitingMerged = true + metrics.refreshGc() + } + .disabled(merged.isEmpty || awaitingMerged || actions.active(for: ActionCenter.machineKey) != nil) .help("stim worktree remove on each worktree stim gc reports as merged") .confirmationDialog( "Remove \(merged.count == 1 ? "1 merged worktree" : "\(merged.count) merged worktrees")?", @@ -140,7 +151,7 @@ struct StorageView: View { } message: { Text( merged.map { PathNames(path: $0.path).title }.joined(separator: ", ") - + ". Each branch is merged into the default branch. stim worktree remove checks each one again and keeps any with uncommitted or unpushed work." + + ". stim gc reports each branch as merged into the default branch. stim worktree remove refuses a worktree with uncommitted or unpushed work." ) } } @@ -168,7 +179,7 @@ struct StorageView: View { } } message: { workspace in Text( - "Stim reclaims its build outputs, owned devices and Metro port, then removes the checkout\(workspace.branch.map { " of \($0)" } ?? ""). It refuses when the worktree has uncommitted or unpushed work." + "Stim reclaims its build outputs, owned devices and Metro port, then removes a linked worktree's checkout\(workspace.branch.map { " of \($0)" } ?? ""); a source checkout keeps its tree. It refuses when the worktree has uncommitted or unpushed work." ) } } @@ -288,12 +299,13 @@ struct StorageView: View { icon: "shippingbox" ) { previewButton("Empty\u{2026}", ["gc", "--json", "--cache", cache.name]) + .disabled(!cache.selectedAlone(among: report.caches)) } } if let devices = report.reclaimableDevices { Rectangle().fill(Theme.border).frame(height: 1) locationRow(devices, icon: "iphone") { - previewButton("Reclaim\u{2026}", ["gc", "--json"]) + previewButton("Reclaim everything safe\u{2026}", ["gc", "--json"]) } } } diff --git a/apps/desktop/Sources/StimKit/Resources.swift b/apps/desktop/Sources/StimKit/Resources.swift index 9ff7ae34e..706a4c9dc 100644 --- a/apps/desktop/Sources/StimKit/Resources.swift +++ b/apps/desktop/Sources/StimKit/Resources.swift @@ -248,6 +248,14 @@ public struct GcReport: Decodable, Sendable { public var bytes: Int64? public var note: String? public var willEmpty: Bool? + + /// Whether `stim gc --cache ` selects this cache alone. The CLI matches the argument as a + /// case-insensitive substring of every cache's name and directory, and reserves `all` and `workspaces`. + public func selectedAlone(among caches: [Cache]) -> Bool { + let wanted = name.trimmingCharacters(in: .whitespaces).lowercased() + guard !wanted.isEmpty, wanted != "all", wanted != "workspaces" else { return false } + return caches.filter { $0.name.lowercased().contains(wanted) || $0.dir.lowercased().contains(wanted) }.count == 1 + } } public struct Sections: Decodable, Sendable { diff --git a/apps/desktop/Tests/StimKitTests/StorageTests.swift b/apps/desktop/Tests/StimKitTests/StorageTests.swift index 076390f3b..f7fd511e2 100644 --- a/apps/desktop/Tests/StimKitTests/StorageTests.swift +++ b/apps/desktop/Tests/StimKitTests/StorageTests.swift @@ -178,6 +178,15 @@ import Testing } @Suite struct GcDeleteScopeTests { + @Test func offersToEmptyACacheOnlyWhenItsNameSelectsItAlone() { + let gradle = GcReport.Cache(name: "Gradle build cache", dir: "/g", bytes: nil, note: nil, willEmpty: nil) + let build = GcReport.Cache(name: "Build cache", dir: "/b", bytes: nil, note: nil, willEmpty: nil) + let cas = GcReport.Cache(name: "Xcode CAS", dir: "/x", bytes: nil, note: nil, willEmpty: nil) + #expect(gradle.selectedAlone(among: [gradle, build, cas])) + #expect(!build.selectedAlone(among: [gradle, build, cas])) + #expect(cas.selectedAlone(among: [gradle, build, cas])) + } + @Test func aScopedPreviewDeletesOnlyThatScope() { #expect(GcPreview.deleteArguments(after: ["gc", "--json"]) == ["gc", "--delete"]) #expect( From ac853cfc238a50aca6c7af0dea1c1fc09ea58260 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 25 Sep 2026 02:31:39 -0400 Subject: [PATCH 3/5] fix(desktop): match gc --idle's build skip and drop a Sendable capture --- apps/desktop/Sources/StimDesktop/AutopilotRunner.swift | 6 +++--- apps/desktop/Sources/StimDesktop/Notifier.swift | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/desktop/Sources/StimDesktop/AutopilotRunner.swift b/apps/desktop/Sources/StimDesktop/AutopilotRunner.swift index 87be02974..02cd14a6d 100644 --- a/apps/desktop/Sources/StimDesktop/AutopilotRunner.swift +++ b/apps/desktop/Sources/StimDesktop/AutopilotRunner.swift @@ -113,10 +113,10 @@ final class AutopilotRunner: ObservableObject { checkPressure() } - /// Booted devices with no build running for them, which `gc --idle` also skips. + /// Booted devices of workspaces with no build running, which `gc --idle` skips. private var bootedDevices: [AutopilotSchedule.Device] { - (status.payload?.environments ?? []).flatMap { env in - env.devices.filter { $0.isRunning && env.runningBuild(for: $0) == nil } + (status.payload?.environments ?? []).filter { $0.build?.isRunning != true }.flatMap { env in + env.devices.filter(\.isRunning) } .map { AutopilotSchedule.Device(activity: $0.activity, screenChangedAt: $0.activityKey.flatMap(ScreenActivity.shared.lastChange)) diff --git a/apps/desktop/Sources/StimDesktop/Notifier.swift b/apps/desktop/Sources/StimDesktop/Notifier.swift index 5b97539f5..caac718f5 100644 --- a/apps/desktop/Sources/StimDesktop/Notifier.swift +++ b/apps/desktop/Sources/StimDesktop/Notifier.swift @@ -32,9 +32,8 @@ final class Notifier: ObservableObject { static func removeDeliveredPressure() { guard isAvailable else { return } - let center = UNUserNotificationCenter.current() - center.getDeliveredNotifications { delivered in - center.removeDeliveredNotifications( + UNUserNotificationCenter.current().getDeliveredNotifications { delivered in + UNUserNotificationCenter.current().removeDeliveredNotifications( withIdentifiers: delivered.map(\.request.identifier).filter { $0.hasPrefix("pressure") }) } } From 3fb70f9bb42aa2f06362694ec3ccc65736c9f93c Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 25 Sep 2026 02:42:16 -0400 Subject: [PATCH 4/5] fix(desktop): split an expression the CI compiler times out on --- apps/desktop/Sources/StimKit/Resources.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/Sources/StimKit/Resources.swift b/apps/desktop/Sources/StimKit/Resources.swift index 706a4c9dc..0b27a36cf 100644 --- a/apps/desktop/Sources/StimKit/Resources.swift +++ b/apps/desktop/Sources/StimKit/Resources.swift @@ -295,9 +295,10 @@ public struct GcReport: Decodable, Sendable { } public var reclaimable: Reclaimable { - let removed = - (sections.orphanedWorkspaces ?? []).map(\.bytes) + deletableDevices.map(\.bytes) - + clearableOutputs.map(\.bytes) + (sections.caches ?? []).filter { $0.willEmpty == true }.map(\.bytes) + var removed: [Int64?] = (sections.orphanedWorkspaces ?? []).map(\.bytes) + removed += deletableDevices.map(\.bytes) + removed += clearableOutputs.map(\.bytes) + removed += (sections.caches ?? []).filter { $0.willEmpty == true }.map(\.bytes) return Reclaimable( bytes: removed.reduce(0) { $0 + ($1 ?? 0) }, entries: removed.count, From 05ad1dabe3eac2f360692d0f0090d018b451b028 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 25 Sep 2026 02:44:28 -0400 Subject: [PATCH 5/5] test(desktop): compare storage sizes as Int64 --- .../Tests/StimKitTests/StorageTests.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/desktop/Tests/StimKitTests/StorageTests.swift b/apps/desktop/Tests/StimKitTests/StorageTests.swift index f7fd511e2..c31aa2e75 100644 --- a/apps/desktop/Tests/StimKitTests/StorageTests.swift +++ b/apps/desktop/Tests/StimKitTests/StorageTests.swift @@ -50,18 +50,20 @@ import Testing environments: [try workspace()], gc: try gc(), sizes: DiskSizes.parse(du), paths: paths) let row = try #require(report.workspaces.first) - #expect(row.buildOutputs == 8192) + #expect(row.buildOutputs == Int64(8192)) #expect(row.buildOutputsKept == "in use: dev server running") - #expect(row.nodeModules == 4096) + #expect(row.nodeModules == Int64(4096)) #expect(row.devices == Int64((10 + 5) * 1024)) #expect(row.deviceCount == 2) #expect(row.worktree?.mergedInto == "origin/main") - #expect(report.reclaimableDevices?.bytes == 3072) - let unmanaged = Dictionary(report.unmanaged.map { ($0.title, $0.bytes) }, uniquingKeysWith: { a, _ in a }) - #expect(unmanaged["Simulators Stim does not own"] == 7 * 1024) - #expect(unmanaged["Gradle caches"] == 6 * 1024 - 2048) - #expect(unmanaged["~/Library/Caches"] == 1024) + #expect(report.reclaimableDevices?.bytes == Int64(3072)) + let unmanaged: [String: Int64] = Dictionary( + report.unmanaged.compactMap { location in location.bytes.map { (location.title, $0) } }, + uniquingKeysWith: { a, _ in a }) + #expect(unmanaged["Simulators Stim does not own"] == Int64(7 * 1024)) + #expect(unmanaged["Gradle caches"] == Int64(6 * 1024 - 2048)) + #expect(unmanaged["~/Library/Caches"] == Int64(1024)) #expect(unmanaged["Xcode DerivedData"] == nil) }