diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 5f33763ab..ae0f382e8 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -7,7 +7,7 @@ and memory (the physical footprint `stim status` measures, or resident memory from an older `stim`). It reads Stim state only through `stim status --watch --json`, `stim status --json`, `stim stats --json`, -`stim logs --json`, `stim settings --json`, `stim ios|android --plan --json`, and the `stim gc --json` dry run, and never reads or writes `$STIM_HOME`. +`stim logs --json`, `stim settings --json`, `stim ios|android --plan --json`, `stim doctor --json`, and the `stim gc --json` dry run, and never reads or writes `$STIM_HOME`. Device replay and a leased physical device's screen, which only stim-server serves, come from the stim-server the Phones tab runs or found, over loopback; see [Replay](#replay) and [Physical devices](#physical-devices). Its actions run the `stim` executable with an argument list, never a shell string, @@ -593,7 +593,9 @@ preference off, followed by SIGKILL if it has not exited after 3 seconds. A killed server leaves its `stim status --watch` child running until that child's next write fails. A server the app did not start keeps running after the app quits. `stim-server` is found on the login shell's `PATH`, or at the path you -choose in the same tab. While a server runs, the tab re-checks it every 5 +choose in the same tab. A test copy can move the port from 7787 with +`defaults write stimServerPort -int `, so it never adopts +the Mac's own server. While a server runs, the tab re-checks it every 5 seconds, and the pairing and device commands use the `STIM_HOME` its health reports, so they act on that server's pairing state. When that `STIM_HOME` is not `~/.stim`, the tab names it and warns that phones paired now are stored @@ -617,7 +619,11 @@ The sheet shows the phone once it pairs. The paired phones list comes from control** badge, its short id, the tailnet node it paired from, when it was last seen, an **Allow control** checkbox, which runs `stim-server devices grant --control` or `--read`, and **Revoke**, which runs `stim-server devices revoke -` after a confirmation. +` after a confirmation. Macs that build here are listed apart, under +**Macs that build here**, without the checkbox: a Mac waiting for approval shows +**Waiting for you** with the time its request lapses, **Review...** and **Deny**; an +approved one shows **Can build** and **Revoke**. See +[Build machines](#build-machines). When the server reports that Tailscale is not running, the tab shows the steps: `tailscale up`, restart the server (a button when the app started it), @@ -635,6 +641,42 @@ http://127.0.0.1:7787`, or the next free port when 7443 is taken. When a route to the server is on a port with Funnel on, the tab says the server is public and pairing fails with the same explanation; it never suggests a Funnel port. +## Build machines + +Another Mac on the tailnet can build for this one once a person on it approves +this Mac (see [Build access](../../packages/server/README.md#build-access)). + +On the Mac that wants to build elsewhere, **Stim > Settings > Build Machines** +lists the entries of the `offload.machines` machine setting, each with its +state from the `buildMachines` field of `stim doctor --json --platform ios`: +**Approved**, **Waiting for approval** (with the request id), **Not asked**, +**Revoked** (revoked, denied, or the request lapsed), **Different Mac** (the +name now belongs to another tailnet node than the one this Mac asked, so Stim +does not connect to it), **Not on the tailnet**, **Tailscale is off**, +**Unreachable** or **Not a tailnet name**. Doctor runs in the first workspace +`stim status` lists, like the doctor checks under Needs attention; with no +workspace listed, the tab says so. While a machine waits for approval the tab +checks again every 15 seconds. Below, **Macs on your tailnet** lists the other +online macOS peers from `tailscale status --json` whose `tailscale serve` route +on port 7443 answers `GET /health` as stim-server. **Use for Builds** adds a Mac +to the setting with `stim settings set offload.machines --scope machine` +and asks it with `stim doctor --json --platform ios --fix`, which also asks +again any listed Mac that has not approved this one. **Ask** and **Ask Again** +run the same `--fix`. **Remove** takes a Mac out of the setting after a +confirmation, unsetting it when the list is empty; removing a **Different Mac** +also runs `--fix`, which forgets the old node so the Mac can be asked again. + +On the Mac that builds, the app checks `stim-server devices --json` every 10 +seconds while a server runs. Each new build request adds " wants to build +on this Mac" to **Notifications** (category **A Mac asks to build here**, +Alert by default) and shows it as a card or a macOS notification. Its +**Review** opens a dialog with the Mac's name, its tailnet node and user, the +request id and when it lapses, and what building here allows. **Allow** runs +`stim-server devices grant --build`, **Deny** runs `stim-server devices +revoke `, and **Later** closes the dialog; Allow is never the default +button, and nothing approves a request without it. The card and the macOS +notification go away once the request is answered or lapses. + ## Notifications Stim Desktop notifies with the phone app's oversight rules @@ -656,7 +698,8 @@ through stim-server counts as an agent driving it, because Desktop cannot read stim-server's leases. Each category has a level, with the phone's names: **Alert**, **Silent** or -**Off**. Every category is Silent by default. An Alert +**Off**. Every category is Silent by default, except **A Mac asks to build +here**, which is Alert because a request lapses after 15 minutes. An Alert appears as a card in the main window's top right corner while that window is in front, newest on top, with its call to action (**Open workspace**, **Show device**, **Show page**, **Show build**, **Show machine**) and a dismiss button; clicking the @@ -676,6 +719,8 @@ machine), and **Mark all read** and **Clear** act on what the filters show. The history keeps the last 200 notifications from the last 7 days in `notifications.json` in Stim Desktop's Application Support folder. +Build requests from other Macs also land here; see [Build machines](#build-machines). + **Settings > App > Notify when** sets each category's level, the stuck threshold (5 to 60 minutes, 15 by default) and quiet hours, stored in `UserDefaults`. During quiet hours an Alert is delivered as Silent. The rules always run every diff --git a/apps/desktop/Sources/StimDesktop/BuildRequests.swift b/apps/desktop/Sources/StimDesktop/BuildRequests.swift new file mode 100644 index 000000000..198483a05 --- /dev/null +++ b/apps/desktop/Sources/StimDesktop/BuildRequests.swift @@ -0,0 +1,101 @@ +import AppKit +import Combine +import StimKit +import UserNotifications + +/// Tells the user when another Mac asks to build on this one: an inbox entry, and a toast or a macOS notification. +/// Only a person answers a request, in `BuildRequestPrompt`. +@MainActor +final class BuildRequestNotifier { + static let shared = BuildRequestNotifier(server: .shared) + + private let server: ServerController + private var subscription: AnyCancellable? + private var announced: [String: String] = [:] + + init(server: ServerController) { + self.server = server + } + + func start() { + guard subscription == nil else { return } + subscription = server.$devices.sink { [weak self] devices in self?.receive(devices) } + } + + private func receive(_ devices: [PairedDevice]) { + let pending = devices.filter { $0.pendingUntil != nil } + let ids = Set(pending.map(\.id)) + for (id, entry) in announced where !ids.contains(id) { + withdraw(id) + NotificationInbox.shared.markRead(entry) + announced[id] = nil + } + for device in pending where announced[device.id] == nil { announced[device.id] = announce(device) } + } + + static func notificationID(_ id: String) -> String { "build-request:\(id)" } + + private func announce(_ device: PairedDevice) -> String { + let notification = OversightNotification( + id: Self.notificationID(device.id), category: .buildRequest, title: "\(device.name) wants to build on this Mac", + body: "From \(device.node). Review it to allow or deny.", quiet: false, thread: "build-requests", + target: .buildRequest(id: device.id)) + let clock = Calendar.current.dateComponents([.hour, .minute], from: Date()) + let quiet = NotificationSettings.isQuiet(.standard, minuteOfDay: (clock.hour ?? 0) * 60 + (clock.minute ?? 0)) + let delivery = Inbox.delivery(NotificationSettings.level(.buildRequest, .standard), quiet: quiet) + let entry = InboxEntry(notification: notification, date: Date(), suppressed: delivery.suppressed) + NotificationInbox.shared.add(entry) + guard delivery.interrupts else { return entry.id } + if OversightNotifier.mainWindowInFront { + ToastCenter.shared.show(OversightNotifier.toast(notification, entry: entry.id)) + } else { + Notifier.postOversight(notification, entry: entry.id) + } + return entry.id + } + + private func withdraw(_ id: String) { + let key = Self.notificationID(id) + ToastCenter.shared.dismiss(key: key) + guard Notifier.isAvailable else { return } + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequests(withIdentifiers: [Notifier.oversightPrefix + key]) + center.removeDeliveredNotifications(withIdentifiers: [Notifier.oversightPrefix + key]) + } +} + +/// The only place a build request is answered: a dialog naming the requesting Mac and its tailnet node. +@MainActor +enum BuildRequestPrompt { + static func present(id: String) { + let server = ServerController.shared + NSApp.activate(ignoringOtherApps: true) + guard let device = server.devices.first(where: { $0.id == id && $0.pendingUntil != nil }) else { + let alert = NSAlert() + alert.messageText = "This build request is no longer pending" + alert.informativeText = + "It was allowed or denied, or it lapsed after 15 minutes. Settings > Phones lists the Macs that build here." + alert.runModal() + return + } + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "\(device.name) wants to build on this Mac" + let lapses = device.pendingUntil.map { " It lapses at \($0.formatted(date: .omitted, time: .shortened))." } ?? "" + alert.informativeText = """ + Tailnet node: \(device.node) + Request: \(device.id).\(lapses) + + Allow only a Mac you expect. It can then run its project's code on this Mac to build: config plugins, CocoaPods hooks, Xcode script phases and Gradle plugins run as your user. It cannot read your workspaces or control your devices. Revoke it any time in Settings > Phones. + """ + let allow = alert.addButton(withTitle: "Allow") + allow.keyEquivalent = "" + alert.addButton(withTitle: "Deny") + alert.addButton(withTitle: "Later").keyEquivalent = "\u{1b}" + switch alert.runModal() { + case .alertFirstButtonReturn: server.allowBuild(device) + case .alertSecondButtonReturn: server.revoke(device) + default: break + } + } +} diff --git a/apps/desktop/Sources/StimDesktop/OversightNotifier.swift b/apps/desktop/Sources/StimDesktop/OversightNotifier.swift index b95cf140c..b2cdb8e27 100644 --- a/apps/desktop/Sources/StimDesktop/OversightNotifier.swift +++ b/apps/desktop/Sources/StimDesktop/OversightNotifier.swift @@ -115,6 +115,7 @@ final class OversightNotifier: ObservableObject { case .finished: return .success case .stuck, .looping: return .warning case .machine, .control: return .error + case .buildRequest: return .warning } } } @@ -125,6 +126,10 @@ enum NoticeRouter { NSWorkspace.shared.open(link) return } + if case .buildRequest(let id) = target { + DispatchQueue.main.async { MainActor.assumeIsolated { BuildRequestPrompt.present(id: id) } } + return + } NSApp.activate(ignoringOtherApps: true) if let window = NSApp.windows.first(where: { $0.identifier?.rawValue.hasPrefix("main") == true }) { if window.isMiniaturized { window.deminiaturize(nil) } diff --git a/apps/desktop/Sources/StimDesktop/ServerController.swift b/apps/desktop/Sources/StimDesktop/ServerController.swift index 079aa68ca..1eb37f90b 100644 --- a/apps/desktop/Sources/StimDesktop/ServerController.swift +++ b/apps/desktop/Sources/StimDesktop/ServerController.swift @@ -26,7 +26,17 @@ final class ServerController: ObservableObject { private var generation = 0 private var devicesEpoch = 0 - var port: Int { StimServerCLI.defaultPort } + static let devicesInterval: Duration = .seconds(10) + + var port: Int { + let port = UserDefaults.standard.integer(forKey: AppPreferences.Key.stimServerPort) + return (1...65535).contains(port) ? port : StimServerCLI.defaultPort + } + + /// Build clients, and Macs waiting for approval to build here, newest first. + var buildClients: [PairedDevice] { devices.filter(\.isBuildClient) } + + var phones: [PairedDevice] { devices.filter { !$0.isBuildClient } } var isRunning: Bool { if case .running = state { return true } @@ -41,6 +51,12 @@ final class ServerController: ObservableObject { func configure(environment: Task<[String: String], Never>) { self.environment = environment if UserDefaults.standard.bool(forKey: AppPreferences.Key.servesPhones) { start() } + Task { + while !Task.isCancelled { + try? await Task.sleep(for: Self.devicesInterval) + if isRunning { reloadDevices() } + } + } } func cli() async -> StimServerCLI { @@ -174,12 +190,26 @@ final class ServerController: ObservableObject { control ? ["read", "control"] : ["read"] } + /// Approves a Mac's pending request to build here. + func allowBuild(_ device: PairedDevice) { + Task { + let cli = await cli() + switch await Task.detached(operation: { Result { try cli.grantBuild(device.id) } }).value { + case .success: changeError = nil + case .failure(let error): changeError = error.localizedDescription + } + devicesEpoch += 1 + reloadDevices() + } + } + func revoke(_ device: PairedDevice) { Task { let cli = await cli() switch await Task.detached(operation: { Result { try cli.revoke(device.id) } }).value { case .success: changeError = nil + devicesEpoch += 1 reloadDevices() case .failure(let error): changeError = error.localizedDescription } diff --git a/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift b/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift index 94312e762..5abb9af33 100644 --- a/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift +++ b/apps/desktop/Sources/StimDesktop/StimDesktopApp.swift @@ -96,6 +96,7 @@ struct StimDesktopApp: App { let cli = Task.detached { StimCLI(environment: await environment.value, override: override) } self.cli = cli ServerController.shared.configure(environment: environment) + BuildRequestNotifier.shared.start() _ = ServerSession.shared let store = StatusStore(cli: cli) _store = StateObject(wrappedValue: store) diff --git a/apps/desktop/Sources/StimDesktop/Views/BuildMachinesView.swift b/apps/desktop/Sources/StimDesktop/Views/BuildMachinesView.swift new file mode 100644 index 000000000..19e8bab1d --- /dev/null +++ b/apps/desktop/Sources/StimDesktop/Views/BuildMachinesView.swift @@ -0,0 +1,290 @@ +import StimKit +import SwiftUI + +/// This Mac's side of build offload: the `offload.machines` it builds on, with each one's state from `stim doctor`, +/// and the other Macs on the tailnet that run stim-server. It changes the setting with `stim settings` and asks for +/// access with `stim doctor --fix`; approving happens on the other Mac. +struct BuildMachinesView: View { + var cli: Task + @ObservedObject var store: StatusStore + var onSettingChanged: () -> Void + + @State private var entries: [String]? + @State private var statuses: [BuildMachineStatus]? + @State private var macs: [TailnetMac]? + @State private var stimMacs: Set = [] + @State private var probing = false + @State private var working: String? + @State private var failure: String? + @State private var removing: String? + @State private var runs = 0 + @State private var latestRun = 0 + + private var checkout: String? { + doctorCheckouts(store.payload?.environments ?? [], project: store.project(ofPath:)).first?.path + } + + var body: some View { + Form { + Section { + if let failure { + Text(failure).foregroundStyle(Palette.error).textSelection(.enabled) + } + if let entries { + if entries.isEmpty { + Text("This Mac builds only on itself.").foregroundStyle(Palette.secondary) + } + ForEach(entries, id: \.self) { entry in + MachineRow( + entry: entry, status: statuses?.first { $0.machine == entry }, checking: statuses == nil, + working: working == entry, canAsk: checkout != nil + ) { + ask(entry) + } remove: { + removing = entry + } + } + } else { + ProgressView().frame(maxWidth: .infinity) + } + } header: { + HStack { + Text("This Mac builds on") + Spacer() + Button("Refresh") { Task { await load() } } + .disabled(working != nil || runs > 0 || probing) + } + } footer: { + Text(footer) + .foregroundStyle(Palette.tertiary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Section { + discovered + } header: { + Text("Macs on your tailnet") + } footer: { + Text( + "Macs that answer as stim-server on their tailscale serve route, port \(String(Tailnet.servePort)). On a Mac that should build for others, turn on Serve to phones in Stim Desktop's Phones tab and add the route it shows." + ) + .foregroundStyle(Palette.tertiary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .background(Palette.background) + .task { await load() } + .task(id: waiting) { + while waiting, !Task.isCancelled { + try? await Task.sleep(for: .seconds(15)) + if working == nil, runs == 0, !Task.isCancelled { await refreshStatuses(ask: false) } + } + } + .confirmationDialog( + "Stop building on \(removing ?? "")?", isPresented: .init(get: { removing != nil }, set: { if !$0 { removing = nil } }), + presenting: removing + ) { entry in + Button("Remove", role: .destructive) { remove(entry) } + } message: { entry in + Text(removalMessage(entry)) + } + } + + private func removalMessage(_ entry: String) -> String { + guard statuses?.first(where: { $0.machine == entry })?.state == .nodeChanged else { + return "Removes it from offload.machines. The next stim doctor --fix forgets its pairing." + } + return + "Removes it from offload.machines and runs stim doctor --fix, which forgets the old node and asks again any listed Mac that has not approved this one." + } + + private var waiting: Bool { statuses?.contains { $0.state == .pending } == true } + + private var footer: String { + let base = + "offload.machines on this Mac. Use for builds adds a Mac and asks it for access with stim doctor --fix, which also asks again any listed Mac that has not approved this one. A person on that Mac allows it." + guard let checkout else { + return base + " Stim runs doctor in a workspace, and none is listed yet: start one with Stim first." + } + return base + " Doctor runs in \(abbreviatingHome(checkout))." + } + + @ViewBuilder private var discovered: some View { + let listed = entries ?? [] + if let macs { + let candidates = macs.filter { mac in stimMacs.contains(mac.id) && !listed.contains { OffloadMachines.names($0, mac) } } + if candidates.isEmpty { + HStack(spacing: Space.md) { + if probing { ProgressView().controlSize(.small) } + Text( + probing + ? "Looking for stim-server on \(macs.count) \(macs.count == 1 ? "Mac" : "Macs")\u{2026}" + : macs.isEmpty ? "No other Mac on your tailnet is online." : "No other Mac on your tailnet runs stim-server." + ) + .foregroundStyle(Palette.secondary) + } + } + ForEach(candidates) { mac in + HStack(spacing: Space.lg) { + Image(systemName: "desktopcomputer").font(.system(size: 18)).foregroundStyle(Palette.accent) + VStack(alignment: .leading, spacing: Space.xxs) { + Text(verbatim: mac.hostName).font(.stim(.body, weight: .semibold)).lineLimit(1) + Text(verbatim: mac.dnsName).font(.stim(.caption, mono: true)).foregroundStyle(Palette.secondary) + .lineLimit(1).truncationMode(.middle) + } + Spacer() + if working == mac.machine { ProgressView().controlSize(.small) } + Button("Use for Builds") { use(mac) } + .disabled(working != nil || checkout == nil) + } + .padding(.vertical, Space.xxs) + } + } else if probing { + ProgressView().frame(maxWidth: .infinity) + } else { + Text("Tailscale is not running, so Stim cannot find other Macs.").foregroundStyle(Palette.secondary) + } + } + + private func load() async { + let cli = await cli.value + async let settings = Task.detached { Result { try cli.settings(cwd: NSHomeDirectory()) } }.value + probing = true + let environment = cli.environment + let found = await Task.detached { Tailnet.status(environment: environment).flatMap(Tailnet.macs(statusJSON:)) }.value + macs = found + switch await settings { + case .success(let payload): + entries = payload.entry("offload.machines")?.value.strings ?? [] + failure = payload.entry("offload.machines") == nil ? "This stim has no offload.machines setting; update it." : nil + case .failure(let error): failure = error.localizedDescription + } + await refreshStatuses(ask: false) + var serving: Set = [] + await withTaskGroup(of: (String, Bool).self) { group in + for mac in found ?? [] { group.addTask { (mac.id, await Tailnet.servesStim(dnsName: mac.dnsName)) } } + for await (id, serves) in group where serves { serving.insert(id) } + } + stimMacs = serving + probing = false + } + + private func refreshStatuses(ask: Bool) async { + guard let checkout, ask || !(entries ?? []).isEmpty else { + statuses = [] + return + } + runs += 1 + latestRun += 1 + let run = latestRun + defer { runs -= 1 } + let cli = await cli.value + let result = await Task.detached(operation: { Result { try cli.buildMachines(cwd: checkout, ask: ask) } }).value + guard run == latestRun else { return } + switch result { + case .success(let reported): + statuses = reported ?? [] + if reported == nil { failure = "This stim does not report build machines; update it." } + case .failure(let error): + statuses = [] + failure = "stim doctor failed in \(abbreviatingHome(checkout)): \(error.localizedDescription)" + } + } + + private func use(_ mac: TailnetMac) { + write(mac.machine, value: OffloadMachines.adding(mac.machine, to: entries ?? []), ask: true) + } + + private func ask(_ entry: String) { + working = entry + Task { + await refreshStatuses(ask: true) + working = nil + } + } + + /// Removing a machine pinned to a node that changed runs `doctor --fix`, which forgets the old pin, so the Mac + /// can be used for builds again. + private func remove(_ entry: String) { + let repins = statuses?.first { $0.machine == entry }?.state == .nodeChanged + write(entry, value: OffloadMachines.removing(entry, from: entries ?? []), ask: repins) + } + + private func write(_ entry: String, value: String?, ask: Bool) { + working = entry + Task { + let cli = await cli.value + let result = await Task.detached { + Result { try cli.writeSetting("offload.machines", value: value, scope: .machine, cwd: NSHomeDirectory()) } + }.value + switch result { + case .success(.written(let setting)): + failure = nil + entries = setting.value.strings ?? [] + onSettingChanged() + statuses = nil + await refreshStatuses(ask: ask) + case .success(.refused(let refusal)): + failure = [refusal.message, refusal.remedy].compactMap { $0 }.joined(separator: " ") + case .failure(let error): failure = error.localizedDescription + } + working = nil + } + } +} + +private struct MachineRow: View { + var entry: String + var status: BuildMachineStatus? + var checking: Bool + var working: Bool + var canAsk: Bool + var ask: () -> Void + var remove: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: Space.lg) { + Image(systemName: "desktopcomputer").font(.system(size: 18)).foregroundStyle(Palette.accent) + VStack(alignment: .leading, spacing: Space.xxs) { + HStack(spacing: Space.sm) { + Text(verbatim: entry).font(.stim(.body, weight: .semibold)).lineLimit(1) + if let status { + Pill(status.state.title, tone: tone(status.state), size: .small) + } else if checking { + Pill("Checking\u{2026}", size: .small) + } + } + if let status { + Text(verbatim: status.detail).font(.stim(.footnote)).foregroundStyle(Palette.secondary) + .fixedSize(horizontal: false, vertical: true) + if let dnsName = status.dnsName { + Text(verbatim: dnsName).font(.stim(.caption, mono: true)).foregroundStyle(Palette.tertiary) + .lineLimit(1).truncationMode(.middle) + } + } + } + Spacer() + if working { ProgressView().controlSize(.small) } + if let status, status.state.canAsk(requested: status.deviceId != nil) { + Button(status.state == .notAsked ? "Ask" : "Ask Again", action: ask).disabled(working || !canAsk) + } + Button("Remove", role: .destructive, action: remove) + .disabled(working || (status?.state == .nodeChanged && !canAsk)) + } + .padding(.vertical, Space.xxs) + } + + private func tone(_ state: BuildMachineStatus.State) -> PillTone { + switch state { + case .approved: return .success + case .pending: return .warning + case .notAsked, .unknown: return .neutral + case .revoked, .nodeChanged, .invalid: return .error + case .notOnTailnet, .tailscaleOff, .unreachable: return .warning + } + } +} diff --git a/apps/desktop/Sources/StimDesktop/Views/PhonesView.swift b/apps/desktop/Sources/StimDesktop/Views/PhonesView.swift index cfc7274ec..897bbe1fb 100644 --- a/apps/desktop/Sources/StimDesktop/Views/PhonesView.swift +++ b/apps/desktop/Sources/StimDesktop/Views/PhonesView.swift @@ -43,10 +43,10 @@ struct PhonesView: View { ForEach([server.devicesError, server.changeError].compactMap { $0 }, id: \.self) { error in Text(abbreviatingHome(error)).foregroundStyle(Palette.error) } - if server.devices.isEmpty { + if server.phones.isEmpty { Text("No paired phones.").foregroundStyle(Palette.secondary) } - ForEach(server.devices) { device in + ForEach(server.phones) { device in DeviceRow( device: device, changing: server.pendingGrants[device.id] != nil, allowControl: { server.grant(device, control: $0) } @@ -61,6 +61,24 @@ struct PhonesView: View { } } + Section { + if server.buildClients.isEmpty { + Text("No Mac builds here.").foregroundStyle(Palette.secondary) + } + ForEach(server.buildClients) { device in + BuildClientRow(device: device, review: { BuildRequestPrompt.present(id: device.id) }) { revoking = device } + } + } header: { + Text("Macs that build here") + } footer: { + Text( + "Another Mac asks from its Build machines tab. Stim Desktop notifies you, and only Allow lets it run its project's code here to build, as your user. A build client never reads workspaces or controls devices. Requests lapse after 15 minutes." + ) + .foregroundStyle(Palette.tertiary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + Section("stim-server executable") { HStack { TextField("stim-server on the login shell's PATH", text: $executable) @@ -88,15 +106,25 @@ struct PhonesView: View { pairing = server.isRunning } .confirmationDialog( - "Revoke \(revoking?.name ?? "")?", isPresented: .init(get: { revoking != nil }, set: { if !$0 { revoking = nil } }), + revokeTitle, isPresented: .init(get: { revoking != nil }, set: { if !$0 { revoking = nil } }), presenting: revoking ) { device in - Button("Revoke", role: .destructive) { server.revoke(device) } - } message: { _ in - Text("The phone disconnects and must pair again to reconnect.") + Button(device.pendingUntil == nil ? "Revoke" : "Deny", role: .destructive) { server.revoke(device) } + } message: { device in + Text( + device.pendingUntil != nil + ? "That Mac cannot build here unless it asks again." + : device.isBuildClient + ? "That Mac can no longer build here and must ask again." + : "The phone disconnects and must pair again to reconnect.") } } + private var revokeTitle: String { + guard let revoking else { return "" } + return revoking.pendingUntil == nil ? "Revoke \(revoking.name)?" : "Deny \(revoking.name)?" + } + @ViewBuilder private var serverState: some View { switch server.state { case .off: @@ -272,6 +300,47 @@ private struct DeviceRow: View { } } +private struct BuildClientRow: View { + var device: PairedDevice + var review: () -> Void + var revoke: () -> Void + + var body: some View { + HStack(spacing: Space.lg) { + Image(systemName: "desktopcomputer").font(.system(size: 18)).foregroundStyle(Palette.accent) + VStack(alignment: .leading, spacing: Space.xxs) { + HStack(spacing: Space.sm) { + Text(verbatim: device.name).font(.stim(.body, weight: .semibold)).lineLimit(1) + if device.pendingUntil != nil { + Pill("Waiting for you", tone: .warning, size: .small) + } else { + Pill("Can build", tone: .success, size: .small) + } + } + Text(verbatim: "\(device.id) \u{00B7} \(device.node)").font(.stim(.caption, mono: true)) + .foregroundStyle(Palette.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Text(detail).font(.stim(.footnote)).foregroundStyle(Palette.secondary) + if device.pendingUntil != nil { + Button("Review\u{2026}", action: review) + Button("Deny", role: .destructive, action: revoke) + } else { + Button("Revoke", role: .destructive, action: revoke) + } + } + .padding(.vertical, Space.xxs) + } + + private var detail: String { + if let until = device.pendingUntil { return "Lapses \(until.formatted(.relative(presentation: .named)))" } + guard let at = device.lastSeenAt else { return "Never built" } + return "Seen \(at.formatted(.relative(presentation: .named)))" + } +} + private struct ScopeBadge: View { var canControl: Bool diff --git a/apps/desktop/Sources/StimDesktop/Views/RootView.swift b/apps/desktop/Sources/StimDesktop/Views/RootView.swift index d3b813ac2..8d8bb888b 100644 --- a/apps/desktop/Sources/StimDesktop/Views/RootView.swift +++ b/apps/desktop/Sources/StimDesktop/Views/RootView.swift @@ -281,6 +281,8 @@ struct RootView: View { if inspector == .hidden { toggleInspector() } case .workspace(let path), .url(let path, _): selection = .environment(path) + case .buildRequest(let id): + BuildRequestPrompt.present(id: id) } } diff --git a/apps/desktop/Sources/StimDesktop/Views/SettingsView.swift b/apps/desktop/Sources/StimDesktop/Views/SettingsView.swift index 5d340c006..7272b2512 100644 --- a/apps/desktop/Sources/StimDesktop/Views/SettingsView.swift +++ b/apps/desktop/Sources/StimDesktop/Views/SettingsView.swift @@ -25,6 +25,9 @@ struct SettingsView: View { PhonesView(server: ServerController.shared, cli: cli) .tabItem { Label("Phones", systemImage: "iphone.gen3.radiowaves.left.and.right") } .tag("phones") + BuildMachinesView(cli: cli, store: store) { model.load(directory: workspace) } + .tabItem { Label("Build Machines", systemImage: "hammer") } + .tag("build-machines") scopeTab(.machine, title: "Machine", icon: "desktopcomputer") scopeTab(.repo, title: "Repository", icon: "folder") scopeTab(.workspace, title: "Workspace", icon: "square.stack.3d.up") diff --git a/apps/desktop/Sources/StimDesktop/Views/Toasts.swift b/apps/desktop/Sources/StimDesktop/Views/Toasts.swift index 27c645d70..4219453cf 100644 --- a/apps/desktop/Sources/StimDesktop/Views/Toasts.swift +++ b/apps/desktop/Sources/StimDesktop/Views/Toasts.swift @@ -43,6 +43,10 @@ final class ToastCenter: ObservableObject { toasts.removeAll { $0.id == id } } + func dismiss(key: String) { + for toast in toasts where toast.key == key { dismiss(toast.id) } + } + func hover(_ id: Toast.ID, _ hovering: Bool) { if hovering { timers.removeValue(forKey: id)?.invalidate() diff --git a/apps/desktop/Sources/StimKit/AppPreferences.swift b/apps/desktop/Sources/StimKit/AppPreferences.swift index 0be0aed47..3eefde822 100644 --- a/apps/desktop/Sources/StimKit/AppPreferences.swift +++ b/apps/desktop/Sources/StimKit/AppPreferences.swift @@ -36,6 +36,8 @@ public enum AppPreferences { public static let notifiesWorktreeRemoval = "notify.worktreeRemoval" public static let servesPhones = "servesPhones" public static let stimServerExecutable = "stimServerExecutable" + /// The loopback port Stim Desktop runs or looks for stim-server on; unset means 7787. Set only with `defaults write`. + public static let stimServerPort = "stimServerPort" public static let showsInspector = "showsInspector" public static let showsLogsPane = "workspace.showsLogsPane" public static let logsPaneWidth = "workspace.logsPaneWidth" diff --git a/apps/desktop/Sources/StimKit/BuildMachines.swift b/apps/desktop/Sources/StimKit/BuildMachines.swift new file mode 100644 index 000000000..b921ee7cb --- /dev/null +++ b/apps/desktop/Sources/StimKit/BuildMachines.swift @@ -0,0 +1,166 @@ +import Foundation + +/// Another Mac on the tailnet, from `tailscale status --json`. +public struct TailnetMac: Hashable, Identifiable, Sendable { + /// The node's StableID. + public var id: String + public var hostName: String + /// The MagicDNS name, lowercased, without the trailing dot. + public var dnsName: String + + /// The `offload.machines` entry that names it: the first label of its MagicDNS name. + public var machine: String { String(dnsName.split(separator: ".").first ?? Substring(dnsName)) } +} + +public enum Tailnet { + static let appBinary = "/Applications/Tailscale.app/Contents/MacOS/Tailscale" + /// The HTTPS port `stim-server` suggests for its `tailscale serve` route, and `offload.machines`' default. + public static let servePort = 7443 + + /// The other macOS peers that are online, by name; nil when Tailscale is not running. + public static func macs(statusJSON: Data) -> [TailnetMac]? { + guard let status = try? JSONSerialization.jsonObject(with: statusJSON) as? [String: Any], + status["BackendState"] as? String == "Running" + else { return nil } + let peers = (status["Peer"] as? [String: Any] ?? [:]).values.compactMap { $0 as? [String: Any] } + return peers.compactMap { peer -> TailnetMac? in + guard peer["OS"] as? String == "macOS", peer["Online"] as? Bool == true, + let id = peer["ID"] as? String, let dns = peer["DNSName"] as? String + else { return nil } + let dnsName = (dns.hasSuffix(".") ? String(dns.dropLast()) : dns).lowercased() + guard !dnsName.isEmpty else { return nil } + return TailnetMac(id: id, hostName: peer["HostName"] as? String ?? dnsName, dnsName: dnsName) + } + .sorted { $0.dnsName < $1.dnsName } + } + + /// `tailscale status --json`, from the CLI on `PATH` or the Mac app's; nil when neither answers. + public static func status(environment: [String: String]) -> Data? { + var environment = environment + let candidates = [resolveExecutable("tailscale", override: nil, environment: &environment), appBinary] + for binary in candidates.compactMap({ $0 }) where FileManager.default.isExecutableFile(atPath: binary) { + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = ["status", "--json"] + process.environment = environment + let out = Pipe() + process.standardOutput = out + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { continue } + let data = out.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + if process.terminationStatus == 0 { return data } + } + return nil + } + + /// Whether `stim-server` answers `GET /health` on the Mac's `tailscale serve` route at `port`. + public static func servesStim(dnsName: String, port: Int = servePort) async -> Bool { + guard let url = URL(string: "https://\(dnsName):\(port)/health") else { return false } + var request = URLRequest(url: url) + request.timeoutInterval = 3 + guard let (data, response) = try? await URLSession.shared.data(for: request), + (response as? HTTPURLResponse)?.statusCode == 200, + let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return false } + return body["server"] as? String == "stim-server" + } +} + +/// Where this Mac stands with one `offload.machines` entry, as `stim doctor --json` reports it. +public struct BuildMachineStatus: Decodable, Hashable, Identifiable, Sendable { + public enum State: String, Decodable, Sendable { + case approved, pending, revoked, invalid, unreachable, unknown + case notAsked = "not-asked" + case nodeChanged = "node-changed" + case notOnTailnet = "not-on-tailnet" + case tailscaleOff = "tailscale-off" + + public init(from decoder: Decoder) throws { + self = State(rawValue: try decoder.singleValueContainer().decode(String.self)) ?? .unknown + } + + public var title: String { + switch self { + case .approved: return "Approved" + case .pending: return "Waiting for approval" + case .notAsked: return "Not asked" + case .revoked: return "Revoked" + case .nodeChanged: return "Different Mac" + case .notOnTailnet: return "Not on the tailnet" + case .tailscaleOff: return "Tailscale is off" + case .unreachable: return "Unreachable" + case .invalid: return "Not a tailnet name" + case .unknown: return "Unknown" + } + } + + /// Whether asking again through `stim doctor --fix` can change it, given whether this Mac already has a request. + public func canAsk(requested: Bool) -> Bool { + self == .notAsked || self == .revoked || (self == .unreachable && !requested) + } + } + + public var machine: String + public var state: State + public var dnsName: String? + public var deviceId: String? + public var requestedAt: String? + + public var id: String { machine } + + public init(machine: String, state: State, dnsName: String? = nil, deviceId: String? = nil) { + self.machine = machine + self.state = state + self.dnsName = dnsName + self.deviceId = deviceId + } + + public var detail: String { + switch state { + case .approved: return "Builds can run on this Mac." + case .pending: + let grant = deviceId.map { " or runs stim-server devices grant \($0) --build there" } ?? "" + return "Someone on \(machine) allows it in Stim Desktop\(grant). The request lapses after 15 minutes." + case .notAsked: return "This Mac has not asked it yet." + case .revoked: return "It revoked this Mac, denied the request, or the request lapsed." + case .nodeChanged: + return + "The name now belongs to a different tailnet node than the one this Mac asked, so Stim does not connect to it. If that Mac was replaced, remove it here, which forgets the old node, then use it for builds again." + case .notOnTailnet: return "No Mac on this tailnet has that name." + case .tailscaleOff: return "Start Tailscale to reach it." + case .unreachable: return "stim-server did not answer on its tailscale serve route." + case .invalid: return "Expected a MagicDNS name, optionally with :port." + case .unknown: return "Update Stim Desktop to show this state." + } + } +} + +/// `offload.machines` edits, as the JSON text `stim settings set` takes. +public enum OffloadMachines { + /// The name part of an entry, without its port. + public static func name(_ entry: String) -> String { + String(entry.split(separator: ":").first ?? Substring(entry)).lowercased() + } + + /// Whether an entry names `mac`: its first label or its full MagicDNS name. + public static func names(_ entry: String, _ mac: TailnetMac) -> Bool { + let name = name(entry) + return name == mac.machine || name == mac.dnsName + } + + /// The list with `entry` added, as JSON text. + public static func adding(_ entry: String, to entries: [String]) -> String { + encode(entries.contains(entry) ? entries : entries + [entry]) + } + + /// The list without `entry` as JSON text, or nil when it is empty and the setting should be unset. + public static func removing(_ entry: String, from entries: [String]) -> String? { + let rest = entries.filter { $0 != entry } + return rest.isEmpty ? nil : encode(rest) + } + + private static func encode(_ entries: [String]) -> String { + String(decoding: (try? JSONEncoder().encode(entries)) ?? Data("[]".utf8), as: UTF8.self) + } +} diff --git a/apps/desktop/Sources/StimKit/Doctor.swift b/apps/desktop/Sources/StimKit/Doctor.swift index 7c7d0f690..8b7a603bc 100644 --- a/apps/desktop/Sources/StimKit/Doctor.swift +++ b/apps/desktop/Sources/StimKit/Doctor.swift @@ -13,6 +13,8 @@ public struct DoctorReport: Decodable, Hashable, Sendable { public var project: String public var findings: [Finding] + /// Each `offload.machines` entry's state; nil from a `stim` older than the field. + public var buildMachines: [BuildMachineStatus]? } /// The first `stim ...` command a doctor fix names in backticks, such as `stim doctor --fix --platform android`. diff --git a/apps/desktop/Sources/StimKit/Notices.swift b/apps/desktop/Sources/StimKit/Notices.swift index b7191d18c..21f53feb3 100644 --- a/apps/desktop/Sources/StimKit/Notices.swift +++ b/apps/desktop/Sources/StimKit/Notices.swift @@ -10,6 +10,7 @@ extension OversightCategory { case .finished: return "Work finished or PR ready" case .machine: return "Machine in trouble" case .control: return "Someone takes over your device" + case .buildRequest: return "A Mac asks to build here" } } @@ -22,19 +23,20 @@ extension OversightCategory { case .finished: return "checkmark.circle" case .machine: return "exclamationmark.triangle" case .control: return "hand.raised" + case .buildRequest: return "hammer" } } /// Whether a toast of the category stays until dismissed: it asks for the user, not just reports. public var needsAttention: Bool { switch self { - case .stuck, .looping, .machine, .control: return true + case .stuck, .looping, .machine, .control, .buildRequest: return true case .started, .finished: return false } } /// The categories that can fire on Stim Desktop; `control` is a stim-server push to a phone. - public static let desktop: [OversightCategory] = [.started, .stuck, .looping, .finished, .machine] + public static let desktop: [OversightCategory] = [.started, .stuck, .looping, .finished, .machine, .buildRequest] } extension OversightTarget { @@ -46,12 +48,13 @@ extension OversightTarget { case .device(_, let platform, _): return platform == "web" ? "Show page" : "Show device" case .build: return "Show build" case .url: return "Open pull request" + case .buildRequest: return "Review" } } /// Every string `actionTitle` can render, for UI that reserves a column sized to the widest one. public static let actionTitles: [String] = [ - "Show machine", "Open workspace", "Show device", "Show page", "Show build", "Open pull request", + "Show machine", "Open workspace", "Show device", "Show page", "Show build", "Open pull request", "Review", ] } @@ -64,7 +67,9 @@ public enum NotificationLevel: String, CaseIterable, Sendable { } extension OversightCategory { - public var defaultLevel: NotificationLevel { .silent } + /// Every category starts Silent, except a build request: it waits on an answer from this Mac and lapses after + /// 15 minutes. + public var defaultLevel: NotificationLevel { self == .buildRequest ? .alert : .silent } } /// Stim Desktop's notification settings, in its own `UserDefaults`. diff --git a/apps/desktop/Sources/StimKit/Oversight.swift b/apps/desktop/Sources/StimKit/Oversight.swift index a04e94284..e7415c403 100644 --- a/apps/desktop/Sources/StimKit/Oversight.swift +++ b/apps/desktop/Sources/StimKit/Oversight.swift @@ -5,6 +5,8 @@ import Foundation /// `Fixtures/oversight-vectors.json`, so the two fail until they agree. public enum OversightCategory: String, Codable, CaseIterable, Hashable, Sendable { case started, stuck, looping, finished, machine, control + /// Stim Desktop only: another Mac asks to build on this one. + case buildRequest = "build-request" } /// The part of a `stim status --json` payload the rules read. @@ -177,8 +179,10 @@ public enum OversightTarget: Codable, Hashable, Sendable { case device(path: String, platform: String, slot: String) case build(path: String, platform: String) case url(path: String, url: String) + /// A pending `stim-server` build request, by the id `stim-server devices` lists it under. + case buildRequest(id: String) - private enum Keys: String, CodingKey { case kind, path, platform, slot, url } + private enum Keys: String, CodingKey { case kind, path, platform, slot, url, id } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: Keys.self) @@ -194,6 +198,7 @@ public enum OversightTarget: Codable, Hashable, Sendable { path: try c.decode(String.self, forKey: .path), platform: try c.decode(String.self, forKey: .platform)) case "url": self = .url(path: try c.decode(String.self, forKey: .path), url: try c.decode(String.self, forKey: .url)) + case "build-request": self = .buildRequest(id: try c.decode(String.self, forKey: .id)) case let kind: throw DecodingError.dataCorruptedError(forKey: .kind, in: c, debugDescription: "Unknown target \(kind)") } @@ -220,13 +225,16 @@ public enum OversightTarget: Codable, Hashable, Sendable { try c.encode("url", forKey: .kind) try c.encode(path, forKey: .path) try c.encode(url, forKey: .url) + case .buildRequest(let id): + try c.encode("build-request", forKey: .kind) + try c.encode(id, forKey: .id) } } /// The workspace the target is in; nil for the machine. public var path: String? { switch self { - case .machine: return nil + case .machine, .buildRequest: return nil case .workspace(let path), .device(let path, _, _), .build(let path, _), .url(let path, _): return path } } diff --git a/apps/desktop/Sources/StimKit/StimCLI.swift b/apps/desktop/Sources/StimKit/StimCLI.swift index b90283a8b..e35305c14 100644 --- a/apps/desktop/Sources/StimKit/StimCLI.swift +++ b/apps/desktop/Sources/StimKit/StimCLI.swift @@ -77,6 +77,14 @@ public struct StimCLI: Sendable { try JSONDecoder().decode(DoctorReport.self, from: run(["doctor", "--json"], cwd: cwd)) } + /// The `offload.machines` states from `stim doctor --json --platform ios` in `cwd`. With `ask`, `--fix` also asks + /// each named machine this Mac has no pairing with for build access, and again one that revoked it; the iOS + /// platform keeps `--fix` from cleaning Android build state in that checkout. + public func buildMachines(cwd: String, ask: Bool) throws -> [BuildMachineStatus]? { + let args = ["doctor", "--json", "--platform", "ios"] + (ask ? ["--fix"] : []) + return try JSONDecoder().decode(DoctorReport.self, from: run(args, cwd: cwd)).buildMachines + } + /// `stim settings --json` in `cwd`: every setting with its origin and layers. public func settings(cwd: String) throws -> SettingsPayload { try JSONDecoder().decode(SettingsPayload.self, from: run(["settings", "--json"], cwd: cwd)) diff --git a/apps/desktop/Sources/StimKit/StimServer.swift b/apps/desktop/Sources/StimKit/StimServer.swift index 65f5bf9ab..b46015b46 100644 --- a/apps/desktop/Sources/StimKit/StimServer.swift +++ b/apps/desktop/Sources/StimKit/StimServer.swift @@ -100,10 +100,15 @@ public struct PairedDevice: Decodable, Equatable, Identifiable, Sendable { public var pairedAt: Date public var lastSeenAt: Date? public var capabilities: [String] + /// When a Mac's request to build here lapses; set only while it waits for approval. + public var pendingUntil: Date? /// Whether the device may run actions and drive devices, not only read. public var canControl: Bool { capabilities.contains("control") } + /// A Mac that asked to build here or may build here, rather than a phone or app that reads. + public var isBuildClient: Bool { pendingUntil != nil || capabilities.contains("build") } + /// The tailnet node the device paired from, or this Mac for a loopback pairing. public var node: String { guard identity.kind == "tailnet" else { return "This Mac" } @@ -176,6 +181,11 @@ public struct StimServerCLI: Sendable { _ = try run(["devices", "grant", id, control ? "--control" : "--read"]) } + /// Approves a Mac's request to build here. + public func grantBuild(_ id: String) throws { + _ = try run(["devices", "grant", id, "--build"]) + } + public func revoke(_ id: String) throws { _ = try run(["devices", "revoke", id]) } diff --git a/apps/desktop/Tests/StimKitTests/BuildMachinesTests.swift b/apps/desktop/Tests/StimKitTests/BuildMachinesTests.swift new file mode 100644 index 000000000..2deb6284f --- /dev/null +++ b/apps/desktop/Tests/StimKitTests/BuildMachinesTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing + +@testable import StimKit + +@Suite struct BuildMachinesTests { + @Test func listsOnlyOtherMacsThatAreOnline() throws { + let status = #""" + {"BackendState":"Running","Self":{"ID":"nSelf","OS":"macOS","DNSName":"laptop.tail1.ts.net.","Online":true}, + "Peer":{ + "a":{"ID":"nMini","HostName":"Mac mini","DNSName":"Mini.tail1.ts.net.","OS":"macOS","Online":true}, + "b":{"ID":"nOff","HostName":"old","DNSName":"old.tail1.ts.net.","OS":"macOS","Online":false}, + "c":{"ID":"nPhone","HostName":"localhost","DNSName":"iphone.tail1.ts.net.","OS":"iOS","Online":true}, + "d":{"ID":"nFunnel","HostName":"funnel-ingress-node","DNSName":"","Online":true}}} + """# + let macs = try #require(Tailnet.macs(statusJSON: Data(status.utf8))) + #expect(macs == [TailnetMac(id: "nMini", hostName: "Mac mini", dnsName: "mini.tail1.ts.net")]) + #expect(macs.first?.machine == "mini") + #expect(Tailnet.macs(statusJSON: Data(#"{"BackendState":"Stopped"}"#.utf8)) == nil) + } + + @Test func readsEachMachineStateFromDoctorAndToleratesNewOnes() throws { + let report = try JSONDecoder().decode( + DoctorReport.self, + from: Data( + #""" + {"project":"/p","findings":[],"buildMachines":[ + {"machine":"mini","state":"pending","dnsName":"mini.tail1.ts.net","deviceId":"ab12","requestedAt":"2026-09-28T12:00:00.000Z"}, + {"machine":"old:7444","state":"node-changed"},{"machine":"x","state":"from-the-future"}]} + """#.utf8)) + let machines = try #require(report.buildMachines) + #expect(machines.map(\.state) == [.pending, .nodeChanged, .unknown]) + #expect(machines[0].detail.contains("stim-server devices grant ab12 --build")) + #expect(try JSONDecoder().decode(DoctorReport.self, from: Data(#"{"project":"/p","findings":[]}"#.utf8)) + .buildMachines == nil) + } + + @Test func editsTheSettingAndUnsetsItWhenEmpty() { + #expect(OffloadMachines.adding("mini", to: ["studio:7444"]) == #"["studio:7444","mini"]"#) + #expect(OffloadMachines.adding("mini", to: ["mini"]) == #"["mini"]"#) + #expect(OffloadMachines.removing("studio:7444", from: ["studio:7444", "mini"]) == #"["mini"]"#) + #expect(OffloadMachines.removing("mini", from: ["mini"]) == nil) + let mini = TailnetMac(id: "n", hostName: "Mac mini", dnsName: "mini.tail1.ts.net") + #expect(OffloadMachines.names("Mini:7444", mini) && OffloadMachines.names("mini.tail1.ts.net", mini)) + #expect(!OffloadMachines.names("minimal", mini)) + } + + @Test func separatesBuildClientsFromPhones() throws { + let devices = try StimServerCLI.decoder.decode( + PairedDeviceList.self, + from: Data( + #""" + {"devices":[ + {"id":"p1","name":"iPhone","identity":{"kind":"tailnet","nodeName":"iphone"},"pairedAt":"2026-09-28T12:00:00.000Z","capabilities":["read"]}, + {"id":"b1","name":"laptop","identity":{"kind":"tailnet","nodeName":"laptop.tail1.ts.net","nodeId":"nL"},"pairedAt":"2026-09-28T12:00:00.000Z","capabilities":[],"pendingUntil":"2026-09-28T12:15:00.000Z"}, + {"id":"b2","name":"studio","identity":{"kind":"tailnet","nodeName":"studio"},"pairedAt":"2026-09-28T12:00:00.000Z","capabilities":["build"]}]} + """#.utf8) + ).devices + #expect(devices.map(\.isBuildClient) == [false, true, true]) + #expect(devices[1].pendingUntil != nil && devices[2].pendingUntil == nil) + } + + @Test func keepsABuildRequestTargetInTheInbox() throws { + let target = OversightTarget.buildRequest(id: "ab12") + let decoded = try JSONDecoder().decode(OversightTarget.self, from: JSONEncoder().encode(target)) + #expect(decoded == target && decoded.path == nil) + } +} diff --git a/apps/desktop/Tests/StimKitTests/NotificationSettingsTests.swift b/apps/desktop/Tests/StimKitTests/NotificationSettingsTests.swift index a54f08de1..9904029ea 100644 --- a/apps/desktop/Tests/StimKitTests/NotificationSettingsTests.swift +++ b/apps/desktop/Tests/StimKitTests/NotificationSettingsTests.swift @@ -10,12 +10,12 @@ struct NotificationSettingsTests { return defaults } - @Test func defaultsEveryCategoryToSilentAndRunsEveryCategory() throws { + @Test func defaultsEveryCategoryButBuildRequestsToSilentAndRunsEveryCategory() throws { let defaults = try defaults("NotificationSettingsTests.fresh") let levels = OversightCategory.desktop.map { NotificationSettings.level($0, defaults) } - #expect(levels == [.silent, .silent, .silent, .silent, .silent]) + #expect(levels == [.silent, .silent, .silent, .silent, .silent, .alert]) let prefs = NotificationSettings.prefs(defaults) - #expect(prefs.categories == [.started, .stuck, .looping, .finished, .machine]) + #expect(prefs.categories == [.started, .stuck, .looping, .finished, .machine, .buildRequest]) #expect(prefs.stuckMinutes == 15) #expect(prefs.quiet == false) } diff --git a/packages/server/README.md b/packages/server/README.md index 58191e822..be6a00e8c 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -39,9 +39,12 @@ started with. While Tailscale runs, it also carries `route`, read from `port` that proxies to the server, `funneled` with the Funnel `ports` that do, `missing`, or `unknown` with a `reason`; the last three carry the `port` the setup command would use. Stim Desktop uses it to find a running server and -show its route. A request through -`tailscale serve`, on a Tailscale address, or with a `Host` other than -`127.0.0.1` or `localhost` gets HTTP 426, like any other plain HTTP request. +show its route. A request through `tailscale serve` or on a Tailscale address +without an `Origin` or `Sec-Fetch-Site` header, which a web page's request +carries, gets only `{ "server": "stim-server", "version", "protocol" }`, which Stim +Desktop's Build machines list uses to find stim-server on the other Macs of the +tailnet. A request from this Mac with a `Host` other than `127.0.0.1` or +`localhost` gets HTTP 426, like any other plain HTTP request. `stim-server` runs the `stim` version this package was released with, not the one on your PATH. It reads the login shell's environment once at start, so @@ -145,7 +148,8 @@ with a `deviceToken`, no capabilities and `approval: { "state": "pending", "expiresAt" }`, and closes the connection. `stim-server devices` lists it as `pending build`. On this Mac, `stim-server devices grant --build` approves it and -`stim-server devices revoke ` denies it. Until then, `hello` with its token +`stim-server devices revoke ` denies it; Stim Desktop's **Allow** and +**Deny** run those commands. Until then, `hello` with its token fails with `approval-pending`, which does not count as a failed attempt, while the request itself does, so a peer cannot send more requests than failed attempts. A request lapses after 15 minutes. Each node has at most one pending diff --git a/packages/server/__tests__/server.test.ts b/packages/server/__tests__/server.test.ts index 252a11c01..f44c3350d 100644 --- a/packages/server/__tests__/server.test.ts +++ b/packages/server/__tests__/server.test.ts @@ -512,7 +512,7 @@ describe('build access', () => { }); describe('health', () => { - it('answers only requests from this Mac', async () => { + it('answers requests from this Mac in full and tailnet peers with the server version only', async () => { const port = await start(); const local = await fetch(`http://127.0.0.1:${port}/health`); expect(local.status).toBe(200); @@ -526,7 +526,12 @@ describe('health', () => { tailscale: { state: 'not-running', backendState: 'Stopped' }, }); const forwarded = await fetch(`http://127.0.0.1:${port}/health`, { headers: { 'x-forwarded-for': '100.64.0.2' } }); - expect(forwarded.status).toBe(426); + expect(forwarded.status).toBe(200); + expect(await forwarded.json()).toEqual({ server: 'stim-server', version: '1.2.3', protocol: 1 }); + const fromPage = await fetch(`http://127.0.0.1:${port}/health`, { + headers: { 'x-forwarded-for': '100.64.0.2', origin: 'http://attacker.example' }, + }); + expect(fromPage.status).toBe(426); const rebound = await new Promise((resolve, reject) => { get({ host: '127.0.0.1', port, path: '/health', headers: { host: `attacker.example:${port}` } }, (response) => { response.resume(); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 085c91715..c9a58df09 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1542,6 +1542,17 @@ export async function startServer(options: ServerOptions): Promise { describe('inspectBuildMachines', () => { it('requests access with --fix and pins the node it asked', async () => { const { io, calls } = fakeIo('nMini', [pending]); - const findings = await inspectBuildMachines({ fix: true }, io, ['mini']); + const { findings, machines } = await inspectBuildMachines({ fix: true }, io, ['mini']); expect(calls).toEqual([ { endpoint: { url: 'wss://100.64.0.7:7443', servername: 'mini.tail1.ts.net', host: 'mini.tail1.ts.net:7443' }, @@ -78,18 +78,25 @@ describe('inspectBuildMachines', () => { }, ]); expect(findings[0]!.fix).toContain('stim-server devices grant ab12 --build'); + expect(machines).toEqual([ + expect.objectContaining({ machine: 'mini', state: 'pending', dnsName: 'mini.tail1.ts.net', deviceId: 'ab12' }), + ]); expect(readBuildMachines()).toEqual([ expect.objectContaining({ machine: 'mini', nodeId: 'nMini', deviceToken: 'secret', state: 'pending' }), ]); - expect(await inspectBuildMachines({ fix: false }, fakeIo('nMini', []).io, [])).toEqual([]); + expect(await inspectBuildMachines({ fix: false }, fakeIo('nMini', []).io, [])).toEqual({ + findings: [], + machines: [], + }); }); it('never sends the token to a node other than the pinned one', async () => { await inspectBuildMachines({ fix: true }, fakeIo('nMini', [pending]).io, ['mini']); const { io, calls } = fakeIo('nImpostor', []); - const findings = await inspectBuildMachines({ fix: true }, io, ['mini']); + const { findings, machines } = await inspectBuildMachines({ fix: true }, io, ['mini']); expect(calls).toEqual([]); expect(findings).toEqual([expect.objectContaining({ title: 'Build machine mini is a different tailnet node' })]); + expect(machines).toEqual([expect.objectContaining({ machine: 'mini', state: 'node-changed' })]); expect(readBuildMachines()[0]!.nodeId).toBe('nMini'); }); @@ -97,14 +104,33 @@ describe('inspectBuildMachines', () => { await inspectBuildMachines({ fix: true }, fakeIo('nMini', [pending]).io, ['mini']); const approved: HelloReply = { result: { capabilities: ['build'], device: { id: 'ab12', name: 'laptop' } } }; const { io, calls } = fakeIo('nMini', [{ error: { code: 'approval-pending', message: 'wait' } }, approved]); - expect(await inspectBuildMachines({ fix: false }, io, ['mini'])).toEqual([ - expect.objectContaining({ title: 'Build machine mini has not approved this Mac yet' }), - ]); - expect(await inspectBuildMachines({ fix: false }, io, ['mini'])).toEqual([]); + expect(await inspectBuildMachines({ fix: false }, io, ['mini'])).toEqual({ + findings: [expect.objectContaining({ title: 'Build machine mini has not approved this Mac yet' })], + machines: [expect.objectContaining({ state: 'pending', deviceId: 'ab12' })], + }); + expect(await inspectBuildMachines({ fix: false }, io, ['mini'])).toEqual({ + findings: [], + machines: [expect.objectContaining({ machine: 'mini', state: 'approved', deviceId: 'ab12' })], + }); expect(calls.map((call) => call.auth)).toEqual([{ deviceToken: 'secret' }, { deviceToken: 'secret' }]); expect(readBuildMachines()[0]!.state).toBe('approved'); }); + it('reports a revoked machine and one never asked without asking either', async () => { + await inspectBuildMachines({ fix: true }, fakeIo('nMini', [pending]).io, ['mini']); + const { io, calls } = fakeIo('nMini', [{ error: { code: 'unauthorized', message: 'Unknown device.' } }]); + const { machines } = await inspectBuildMachines({ fix: false }, io, ['mini', 'minimal', 'nope', 'bad;name']); + expect(machines).toEqual([ + expect.objectContaining({ machine: 'mini', state: 'revoked', deviceId: 'ab12' }), + { machine: 'minimal', state: 'not-asked', dnsName: 'minimal.tail1.ts.net' }, + { machine: 'nope', state: 'not-on-tailnet' }, + { machine: 'bad;name', state: 'invalid' }, + ]); + expect(calls.map((call) => call.auth)).toEqual([{ deviceToken: 'secret' }]); + const off = await inspectBuildMachines({ fix: false }, { status: () => null, hello: io.hello }, ['mini']); + expect(off.machines).toEqual([{ machine: 'mini', state: 'tailscale-off' }]); + }); + it('forgets the pairing of a machine no longer named, with --fix only', async () => { await inspectBuildMachines({ fix: true }, fakeIo('nMini', [pending]).io, ['mini']); await inspectBuildMachines({ fix: false }, fakeIo('nMini', []).io, []); diff --git a/packages/stim-cli/src/commands/doctor.ts b/packages/stim-cli/src/commands/doctor.ts index 721d89273..98eff8451 100644 --- a/packages/stim-cli/src/commands/doctor.ts +++ b/packages/stim-cli/src/commands/doctor.ts @@ -237,11 +237,19 @@ export default function doctorCommand( const budget = await inspectBudget(root); findings.push(...budget.findings); - findings.push(...(await inspectBuildMachines({ fix: opts.fix === true }))); + const buildMachines = await inspectBuildMachines({ fix: opts.fix === true }); + findings.push(...buildMachines.findings); if (opts.json) { console.log( - JSON.stringify({ project: root, platform: opts.platform ?? null, stim, budget: budget.report, findings }), + JSON.stringify({ + project: root, + platform: opts.platform ?? null, + stim, + budget: budget.report, + buildMachines: buildMachines.machines, + findings, + }), ); recordDoctorRun(root, opts.platform, version); return; diff --git a/packages/stim-cli/src/guide/facts.ts b/packages/stim-cli/src/guide/facts.ts index 8af1f0603..910bba047 100644 --- a/packages/stim-cli/src/guide/facts.ts +++ b/packages/stim-cli/src/guide/facts.ts @@ -451,6 +451,11 @@ leased until