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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <bundle id> stimServerPort -int <port>`, 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
Expand All @@ -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 <id>
--control` or `--read`, and **Revoke**, which runs `stim-server devices revoke
<id>` after a confirmation.
<id>` 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),
Expand All @@ -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 <list> --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 "<Mac> 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 <id> --build`, **Deny** runs `stim-server devices
revoke <id>`, 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
Expand All @@ -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
Expand All @@ -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
Expand Down
101 changes: 101 additions & 0 deletions apps/desktop/Sources/StimDesktop/BuildRequests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
5 changes: 5 additions & 0 deletions apps/desktop/Sources/StimDesktop/OversightNotifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand All @@ -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) }
Expand Down
32 changes: 31 additions & 1 deletion apps/desktop/Sources/StimDesktop/ServerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/Sources/StimDesktop/StimDesktopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading