diff --git a/build.sh b/build.sh index f021c8d..a0d8e55 100755 --- a/build.sh +++ b/build.sh @@ -53,6 +53,8 @@ build_app "$APP" "stack-nudge" \ panel/SessionStore.swift \ panel/Sessions.swift \ panel/Phrases.swift \ + panel/UpdateChecker.swift \ + panel/Updater.swift \ panel/Welcome.swift \ shared/AppActivator.swift \ -framework Foundation -framework AppKit -framework SwiftUI -framework Carbon \ diff --git a/install.sh b/install.sh index 13fb708..f5a7dda 100755 --- a/install.sh +++ b/install.sh @@ -21,6 +21,7 @@ PREBUILT_APP="$SCRIPT_DIR/build/stack-nudge.app" BUILD_LOG="/tmp/stack-nudge-install-build.log" if [[ "$(uname -s)" == "Darwin" ]]; then echo "" + echo "# STAGE: building" if [[ -d "$PREBUILT_APP" ]]; then echo "Using prebuilt stack-nudge.app from release bundle..." else @@ -63,6 +64,7 @@ find_python() { # Install the voice engine (stackvox) from PyPI into an isolated venv. echo "" +echo "# STAGE: venv" echo "Setting up voice engine..." STACKVOX_SPEC="stackvox>=0.4.0" PYTHON=$(find_python) @@ -146,6 +148,7 @@ if [[ "$(uname -s)" == "Darwin" ]]; then rotate_log "${INSTALL_DIR}/daemon.log" rotate_log "${INSTALL_DIR}/app.log" + echo "# STAGE: launchd" # Belt-and-suspenders: kill any survivor processes from a prior install # BEFORE we re-register the launchd agents, so the unload-then-load below # doesn't race with an old instance still hanging on. Matching the exact @@ -195,6 +198,7 @@ if [[ ! -f "$INSTALL_DIR/config" && -f "$SCRIPT_DIR/notify.conf.example" ]]; the echo " Created config -> $INSTALL_DIR/config" fi +echo "# STAGE: hooks" # Detect agents and wire up their hooks installed_any=false @@ -297,6 +301,7 @@ if [[ "$installed_any" == "false" ]]; then fi echo "" +echo "# STAGE: done" echo "Done! Hooks are wired up." echo "" echo " ┌──────────────────────────────────────────────┐" diff --git a/panel/Panel.swift b/panel/Panel.swift index f62f17d..cfe7d8e 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -76,6 +76,14 @@ struct PanelContentView: View { WelcomeView(nav: nav, hotkeyDisplay: nav.hotkeyDisplay, onGrantPermissions: onGrantPermissions) + } else if nav.mode == .postUpdate { + // Full-screen takeover, no tab strip — matches welcome's + // single-purpose first-launch feel. + PostUpdateView(nav: nav, onDismiss: { + nav.postUpdateVersion = nil + nav.postUpdateNotes = nil + nav.mode = .events + }) } else { tabStrip Divider().opacity(0.4) @@ -85,6 +93,14 @@ struct PanelContentView: View { case .sessions: SessionsView(store: sessions) case .settings: SettingsView(nav: nav) case .phrases: PhrasesView(model: phrases) { nav.mode = .settings } + case .updateConfirm: + UpdateConfirmView( + nav: nav, + onCancel: { nav.mode = .settings }, + onConfirm: { nav.actions?.runUpdate() } + ) + case .updating: UpdatingView(nav: nav) + case .postUpdate: EmptyView() // handled above } } } @@ -99,7 +115,7 @@ struct PanelContentView: View { tab(.events, label: "Events", count: store.events.count) tab(.sessions, label: "Sessions", count: sessions.sessions.filter { $0.status == .active }.count) - tab(.settings, label: "Settings", count: 0) + tab(.settings, label: "Settings", count: 0, dot: nav.updateAvailable != nil) Spacer() @@ -115,7 +131,7 @@ struct PanelContentView: View { .padding(.vertical, 8) } - private func tab(_ mode: PanelMode, label: String, count: Int) -> some View { + private func tab(_ mode: PanelMode, label: String, count: Int, dot: Bool = false) -> some View { let isActive = nav.mode == mode return Button { nav.mode = mode @@ -132,6 +148,11 @@ struct PanelContentView: View { Capsule().fill(Color.primary.opacity(isActive ? 0.18 : 0.10)) ) } + if dot { + Circle() + .fill(Color.accentColor) + .frame(width: 6, height: 6) + } } .padding(.horizontal, 8) .padding(.vertical, 3) @@ -304,6 +325,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private var listener: EventListener? private var menuBar: MenuBarController? private var permissionsWC: PermissionsWindowController? + private var updateChecker: UpdateChecker? + private var updater: Updater? func applicationDidFinishLaunching(_ notification: Notification) { let frame = NSRect(x: 0, y: 0, width: 420, height: 280) @@ -356,6 +379,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, self?.phrases.selectedRow = nil self?.nav.mode = .phrases }, + beginUpdate: { [weak self] in self?.beginUpdateFlow() }, + runUpdate: { [weak self] in self?.updater?.run() }, quit: { NSApp.terminate(nil) } ) nav.setHotkey = { [weak self] spec in @@ -367,6 +392,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, store.onAppend = { [weak self] event in self?.postBannerIfNeeded(event) } nav.loadFromConfig() // populate panelPinned + other live values up-front + updateChecker = UpdateChecker(nav: nav) + updateChecker?.start() + updater = Updater(nav: nav) + + // If a previous panel instance was pkilled mid-update by install.sh, + // it left a status file behind. Read it now and surface a brief toast + // so the user knows the update completed (or failed). + if let result = Updater.consumePostUpdateStatus() { + handlePostUpdateStatus(result: result) + } + // First-run welcome: auto-open the panel if STACKNUDGE_WELCOMED isn't // set yet. Brief delay so install.sh's launchctl bounce settles. // Permission prompts are user-triggered from the welcome screen, @@ -399,6 +435,52 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, showPermissions() } + // Click on the "Update available" row → load release notes (if not + // already populated by the background checker) and switch to the + // confirmation mode. The actual install kicks off only when the user + // hits Update Now / Enter from the confirm view. + private func beginUpdateFlow() { + nav.mode = .updateConfirm + // Fetch release notes lazily if we haven't already. + if nav.updateReleaseNotes == nil { + updateChecker?.fetchReleaseNotes { [weak self] body in + self?.nav.updateReleaseNotes = body + } + } + } + + // Surface the post-update view on first launch after a successful + // update. Sets the version + mode immediately so the user sees the + // confirmation right away, then kicks off an async release-notes + // fetch (gh CLI fallback for private repos) that fills in the body + // when it arrives. Failures during update get a one-line message + // logged to stderr — no UI for that case yet. + private func handlePostUpdateStatus(result: (state: String, version: String, error: String?)) { + switch result.state { + case "success": + nav.postUpdateVersion = result.version.isEmpty ? "?" : result.version + nav.postUpdateNotes = nil + nav.mode = .postUpdate + // Auto-open the panel so the user immediately sees the + // "what shipped" view rather than discovering it via hotkey. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + guard let self else { return } + NSApp.activate(ignoringOtherApps: true) + self.panel.makeKeyAndOrderFront(nil) + } + if !result.version.isEmpty { + updateChecker?.fetchReleaseNotes(for: result.version) { [weak self] body in + self?.nav.postUpdateNotes = body + } + } + case "failed": + FileHandle.standardError.write(Data( + "stack-nudge: previous update failed: \(result.error ?? "unknown")\n".utf8)) + default: + return + } + } + @objc private func panelDidResignKey(_ notification: Notification) { guard !nav.panelPinned, panel.isVisible else { return } hidePanel() @@ -720,6 +802,56 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } } + // Post-update view: Enter or Esc both dismiss to the events tab. + // Mirrors WelcomeView's keyboard contract — single-purpose screen, two + // keys to exit, no other navigation allowed while it's up. + if nav.mode == .postUpdate { + let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty + guard plain else { return true } + switch event.keyCode { + case KeyCode.escape, KeyCode.returnKey, KeyCode.numpadEnter: + nav.postUpdateVersion = nil + nav.postUpdateNotes = nil + nav.mode = .events + return true + default: + return true + } + } + + // Update-confirm: Enter triggers install, Esc cancels back to Settings. + if nav.mode == .updateConfirm { + let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty + guard plain else { return false } + switch event.keyCode { + case KeyCode.escape: + nav.mode = .settings + return true + case KeyCode.returnKey, KeyCode.numpadEnter: + nav.actions?.runUpdate() + return true + default: + return false + } + } + + // Updating: only Esc, and only after a failure (so the user can't + // accidentally abandon a live install). Space toggles the log. + if nav.mode == .updating { + let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty + guard plain else { return false } + switch event.keyCode { + case KeyCode.escape where nav.updaterPhase == .failed: + nav.mode = .settings + return true + case KeyCode.space: + nav.updaterShowLog.toggle() + return true + default: + return false + } + } + // Sessions mode: ↑/↓ select, Enter focus, ⌫/R kill, N rename. // While the rename TextField is active, every key flows through to // SwiftUI; the field handles Enter via .onSubmit and Esc via diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index cacaf2b..be68e7a 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -6,6 +6,18 @@ enum PanelMode { case sessions case settings case phrases + // Confirmation step after the user clicks the "Update available" row. + // Shows release notes (when available) + Cancel / Update Now buttons. + case updateConfirm + // Live install progress driven by Updater. Replaces the panel content + // until the install completes or fails — at which point the panel is + // typically pkilled and respawned by launchd, so this mode is short + // lived in the happy path. + case updating + // Welcome-style "what shipped" view shown automatically on first launch + // after a successful update. Driven by the status file the runner wrote + // before the previous instance died. + case postUpdate } // Action callbacks the controller wires into nav so settings rows like @@ -15,6 +27,8 @@ struct SettingsActions { let checkPermissions: () -> Void let openConfig: () -> Void let editPhrases: () -> Void + let beginUpdate: () -> Void + let runUpdate: () -> Void let quit: () -> Void } @@ -41,6 +55,26 @@ final class PanelNav: ObservableObject { @Published var voiceSpeed: Double = 1.1 @Published var voicesAvailable: [String] = [] @Published var voicesLoading: Bool = true + // The latest release tag from GitHub when newer than this bundle's + // CFBundleShortVersionString — nil otherwise. Drives both the Settings + // tab dot badge and the conditional "Update available" row at the top + // of the Settings list. Populated by UpdateChecker. + @Published var updateAvailable: String? + // Release notes body (markdown) for the available update — shown in the + // confirmation step. nil before notes have loaded or when fetch failed + // (e.g. private repo without auth). + @Published var updateReleaseNotes: String? + // Live updater state. updaterPhase advances as install.sh emits STAGE + // markers; updaterLog accumulates the raw install output for the + // expandable "Show output" detail in UpdatingView. + @Published var updaterPhase: UpdatePhase = .idle + @Published var updaterLog: String = "" + @Published var updaterShowLog: Bool = false + // Post-update screen state. Populated on launch when the status file + // from a previous in-flight update is found; drives the welcome-style + // PostUpdateView (mode = .postUpdate). Cleared on dismiss. + @Published var postUpdateVersion: String? + @Published var postUpdateNotes: String? var actions: SettingsActions? // Wired by PanelController so nav can re-register the global hotkey @@ -73,10 +107,17 @@ final class PanelNav: ObservableObject { "I'd love your input on this.", ] - var rowCount: Int { 13 } + // +1 when an update is available and the "Update to vX.Y.Z" row is + // pinned at the top of the Settings list. All other indices shift down + // when the offset is 1. + var updateRowOffset: Int { updateAvailable != nil ? 1 : 0 } + + var rowCount: Int { 13 + updateRowOffset } // Row layout (kept in one place so the controller, view, and indexing - // logic all agree on what each row index means): + // logic all agree on what each row index means). When updateAvailable + // is non-nil, row 0 becomes "Update to vX.Y.Z" and every following row + // shifts down by one — use `index - updateRowOffset` when matching: // 0 Hotkey hotkey-record // 1 Banner notifications toggle // 2 Voice notifications toggle @@ -161,7 +202,11 @@ final class PanelNav: ObservableObject { // Enter: toggles flip, cycle rows step forward, actions fire, hotkey // row enters record mode. func activate() { - switch selectedSettingIndex { + if updateRowOffset == 1, selectedSettingIndex == 0 { + actions?.beginUpdate() + return + } + switch selectedSettingIndex - updateRowOffset { case 0: startRecordingHotkey() case 9: actions?.editPhrases() case 10: actions?.checkPermissions() @@ -175,7 +220,13 @@ final class PanelNav: ObservableObject { func cycleBackward() { applyCycle(forward: false) } private func applyCycle(forward: Bool) { - switch selectedSettingIndex { + // Update row (when present at index 0) treats left/right arrows the + // same as Enter — there's nothing to cycle, so just begin update. + if updateRowOffset == 1, selectedSettingIndex == 0 { + actions?.beginUpdate() + return + } + switch selectedSettingIndex - updateRowOffset { case 0: // Cycle on the hotkey row also enters record mode. startRecordingHotkey() diff --git a/panel/Settings.swift b/panel/Settings.swift index f7c2543..ca03b89 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -14,8 +14,15 @@ struct SettingsView: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 14) { + // Index 0 when present, shifting everything else by + // +1. The offset is held on nav (updateRowOffset). + if let version = nav.updateAvailable { + updateRow(version: version) + } + let off = nav.updateRowOffset + section("Hotkey") { - row(0, label: "Panel shortcut", + row(0 + off, label: "Panel shortcut", kind: .cycle, value: nav.recordingHotkey ? "Press combo…" : nav.hotkeyDisplay) if let error = nav.hotkeyError { @@ -28,27 +35,27 @@ struct SettingsView: View { } section("Toggles") { - row(1, label: "Banner notifications", kind: .toggle, value: nav.bannerEnabled ? "On" : "Off") - row(2, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") - row(3, label: "Mute when focused", kind: .toggle, value: nav.muteWhenFocused ? "On" : "Off") - row(4, label: "Pin panel", kind: .toggle, value: nav.panelPinned ? "On" : "Off") + row(1 + off, label: "Banner notifications", kind: .toggle, value: nav.bannerEnabled ? "On" : "Off") + row(2 + off, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") + row(3 + off, label: "Mute when focused", kind: .toggle, value: nav.muteWhenFocused ? "On" : "Off") + row(4 + off, label: "Pin panel", kind: .toggle, value: nav.panelPinned ? "On" : "Off") } section("Sounds") { - row(5, label: "Agent done", kind: .cycle, value: nav.soundStop) - row(6, label: "Permission", kind: .cycle, value: nav.soundPermission) + row(5 + off, label: "Agent done", kind: .cycle, value: nav.soundStop) + row(6 + off, label: "Permission", kind: .cycle, value: nav.soundPermission) } section("Voice") { - row(7, label: "Voice", kind: .cycle, value: voiceLabel) - row(8, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed)) + row(7 + off, label: "Voice", kind: .cycle, value: voiceLabel) + row(8 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed)) } section("Actions") { - row(9, label: "Edit phrases…", kind: .action, value: "") - row(10, label: "Check permissions…", kind: .action, value: "") - row(11, label: "Open config file…", kind: .action, value: "") - row(12, label: "Quit panel", kind: .action, value: "") + row(9 + off, label: "Edit phrases…", kind: .action, value: "") + row(10 + off, label: "Check permissions…", kind: .action, value: "") + row(11 + off, label: "Open config file…", kind: .action, value: "") + row(12 + off, label: "Quit panel", kind: .action, value: "") } aboutFooter @@ -89,6 +96,43 @@ struct SettingsView: View { return nav.voice } + // Conditional top-of-list row. Pinned at index 0 when an update is + // available — visually distinct (accent fill always-on) so the user's + // eye lands on it first. Acts on click or Enter; opens the GitHub + // releases page via the openReleasePage action. + @ViewBuilder + private func updateRow(version: String) -> some View { + let selected = nav.selectedSettingIndex == 0 + HStack(spacing: 10) { + Image(systemName: "arrow.down.circle.fill") + .font(.body) + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 1) { + Text("Update available") + .font(.subheadline.weight(.medium)) + Text("v\(version)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(selected ? 0.32 : 0.18)) + ) + .contentShape(Rectangle()) + .id(0) + .onTapGesture { + nav.selectedSettingIndex = 0 + nav.activate() + } + } + // Non-navigable footer with version info. Sits below the action rows so // keyboard nav (rowCount=12) doesn't need to know about it. Clicking the // GitHub link opens the repo in the user's browser. diff --git a/panel/UpdateChecker.swift b/panel/UpdateChecker.swift new file mode 100644 index 0000000..55612b9 --- /dev/null +++ b/panel/UpdateChecker.swift @@ -0,0 +1,184 @@ +import Foundation + +// Polls the GitHub Releases API for stack-nudge and surfaces the latest tag +// when it's newer than this bundle's CFBundleShortVersionString. The result +// is published back to PanelNav (on the main thread) so the tab badge and +// "Update available" row react automatically. +// +// Checks fire on app launch and then every `interval` seconds while the app +// runs. Network failures are swallowed silently — auto-update is a soft +// signal, not a critical path. +final class UpdateChecker { + + static let latestReleaseURL = URL( + string: "https://api.github.com/repos/StackOneHQ/stack-nudge/releases/latest" + )! + static let releasesPageURL = URL( + string: "https://github.com/StackOneHQ/stack-nudge/releases/latest" + )! + static let latestGHPath = "repos/StackOneHQ/stack-nudge/releases/latest" + + // Build URL + gh CLI API path for a specific tag like "1.4.2". Both + // endpoints return the same JSON shape as /latest, so callers can reuse + // the same parsing path. + static func tagReleaseURL(for version: String) -> URL { + URL(string: "https://api.github.com/repos/StackOneHQ/stack-nudge/releases/tags/v\(version)")! + } + static func tagGHPath(for version: String) -> String { + "repos/StackOneHQ/stack-nudge/releases/tags/v\(version)" + } + + private let interval: TimeInterval + private weak var nav: PanelNav? + private var timer: Timer? + private let session: URLSession + + init(nav: PanelNav, interval: TimeInterval = 6 * 60 * 60) { + self.nav = nav + self.interval = interval + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 10 + config.timeoutIntervalForResource = 15 + self.session = URLSession(configuration: config) + } + + func start() { + check() + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + self?.check() + } + } + + func stop() { + timer?.invalidate() + timer = nil + } + + // Public so a "Check for updates now" action can force a refresh; ignored + // result — the publish-to-nav side effect is what matters. + func check() { + guard let current = Self.currentVersion() else { return } + fetchReleaseJSON(url: Self.latestReleaseURL, ghAPIPath: Self.latestGHPath) { [weak self] json in + guard let self, + let tag = json?["tag_name"] as? String + else { return } + let latest = Self.stripV(tag) + let newer = Self.isNewer(latest, than: current) + let body = json?["body"] as? String + DispatchQueue.main.async { + self.nav?.updateAvailable = newer ? latest : nil + self.nav?.updateReleaseNotes = newer ? body : nil + } + } + } + + // One-shot fetch of release notes for the update-confirm view. Used when + // the user clicks the update row before the background check has cached + // notes. Calls completion on the main thread with the body string or nil. + func fetchReleaseNotes(completion: @escaping (String?) -> Void) { + fetchReleaseJSON(url: Self.latestReleaseURL, ghAPIPath: Self.latestGHPath) { json in + DispatchQueue.main.async { completion(json?["body"] as? String) } + } + } + + // Fetch release notes for a specific version tag — used by the post-update + // view to surface exactly what shipped, even if a newer release lands in + // between the install and the relaunch. Same gh-fallback path as the + // /latest case. + func fetchReleaseNotes(for version: String, completion: @escaping (String?) -> Void) { + fetchReleaseJSON( + url: Self.tagReleaseURL(for: version), + ghAPIPath: Self.tagGHPath(for: version) + ) { json in + DispatchQueue.main.async { completion(json?["body"] as? String) } + } + } + + // MARK: - Fetch with gh fallback + + // Tries the unauthenticated GitHub API first. When that returns no usable + // payload (404 because the repo is private, or any other failure), falls + // back to `gh api ` which uses the user's locally-authenticated gh + // CLI. For org members who already have gh set up, this is invisible — + // same ergonomics as a public repo. + private func fetchReleaseJSON( + url: URL, + ghAPIPath: String, + completion: @escaping ([String: Any]?) -> Void + ) { + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("stack-nudge", forHTTPHeaderField: "User-Agent") + session.dataTask(with: request) { [weak self] data, response, _ in + let http = response as? HTTPURLResponse + if let data, + http?.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + completion(json) + return + } + // Public path didn't work — try the local gh CLI. + self?.fetchViaGH(apiPath: ghAPIPath, completion: completion) + }.resume() + } + + // Spawn `gh api ` and parse stdout as JSON. Launchd-launched apps + // have a minimal PATH, so search the common homebrew locations directly + // rather than relying on env. Any failure (missing binary, gh not + // authenticated, network issue, repo not accessible) yields a nil result + // and a no-op upstream. + private func fetchViaGH(apiPath: String, completion: @escaping ([String: Any]?) -> Void) { + let candidates = ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"] + guard let ghPath = candidates.first(where: { + FileManager.default.isExecutableFile(atPath: $0) + }) else { + completion(nil) + return + } + DispatchQueue.global(qos: .utility).async { + let task = Process() + task.executableURL = URL(fileURLWithPath: ghPath) + task.arguments = ["api", apiPath] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + do { try task.run() } catch { + completion(nil); return + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + guard task.terminationStatus == 0, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + completion(nil); return + } + completion(json) + } + } + + // MARK: - Helpers + + static func currentVersion() -> String? { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + } + + static func stripV(_ tag: String) -> String { + tag.hasPrefix("v") ? String(tag.dropFirst()) : tag + } + + // Compare dotted-numeric versions component-wise. Returns true when + // `latest` is strictly greater than `current`. Non-numeric components + // (pre-release suffixes) are treated as 0 — good enough for our release + // cadence; can be revisited if we adopt pre-releases. + static func isNewer(_ latest: String, than current: String) -> Bool { + let l = latest.split(separator: ".").map { Int($0) ?? 0 } + let c = current.split(separator: ".").map { Int($0) ?? 0 } + let count = max(l.count, c.count) + for i in 0.. ci } + } + return false + } +} diff --git a/panel/Updater.swift b/panel/Updater.swift new file mode 100644 index 0000000..8073263 --- /dev/null +++ b/panel/Updater.swift @@ -0,0 +1,673 @@ +import AppKit +import Foundation +import SwiftUI + +// Phase of the auto-update flow. Parsed from `# STAGE: …` markers written +// by install.sh into the runner log file. Drives the progress UI in +// UpdatingView. +enum UpdatePhase: String { + case idle + case cloning + case building + case venv + case launchd + case hooks + case done + case failed +} + +extension UpdatePhase { + // Human-readable label shown alongside the spinner. + var label: String { + switch self { + case .idle: return "Preparing…" + case .cloning: return "Cloning repository…" + case .building: return "Building app…" + case .venv: return "Setting up voice engine…" + case .launchd: return "Registering background agents…" + case .hooks: return "Wiring agent hooks…" + case .done: return "Restarting stack-nudge…" + case .failed: return "Update failed" + } + } + + // Ordinal position used to render the progress bar. `failed` shares the + // last fillable slot so the bar doesn't suddenly empty on failure. + var step: Int { + switch self { + case .idle: return 0 + case .cloning: return 1 + case .building: return 2 + case .venv: return 3 + case .launchd: return 4 + case .hooks: return 5 + case .done, .failed: return 6 + } + } + + static let totalSteps: Int = 6 +} + +// Drives the click-to-update flow. Spawns install.sh in a detached session +// (via Python `os.setsid()`) so it survives the pkill install.sh runs on the +// running panel mid-flight. Tails the runner log file for live phase + log +// updates that the UI binds to. +// +// On completion the runner writes /tmp/stack-nudge-update-status.json so +// the next panel instance (started by launchctl after the swap) can pick up +// where the dying instance left off and show a confirmation toast. +final class Updater { + + // GitHub HTTPS clone URL. SSH (`git@github.com:…`) would require key + // setup; HTTPS works for any user with credential-helper auth (macOS + // keychain or gh CLI integration), which is the org-member default. + static let cloneURL = "https://github.com/StackOneHQ/stack-nudge.git" + + static let logPath = "/tmp/stack-nudge-update.log" + static let statusPath = "/tmp/stack-nudge-update-status.json" + + private weak var nav: PanelNav? + private var tailHandle: DispatchSourceFileSystemObject? + private var tailFD: Int32 = -1 + private var tailOffset: off_t = 0 + private var logBuffer = "" + + init(nav: PanelNav) { + self.nav = nav + } + + // Kicks off the install in a detached session. Returns immediately — + // progress flows back to the panel via nav.updaterPhase / nav.updaterLog. + // The runner survives our death (when install.sh pkills us) because of + // setsid; launchctl reload brings a fresh panel up afterwards. + func run() { + guard let nav else { return } + DispatchQueue.main.async { + nav.updaterPhase = .idle + nav.updaterLog = "" + nav.mode = .updating + } + + // Clean slate: any prior log + status file from a previous run. + try? FileManager.default.removeItem(atPath: Self.logPath) + try? FileManager.default.removeItem(atPath: Self.statusPath) + FileManager.default.createFile(atPath: Self.logPath, contents: nil) + + let runnerPath = "/tmp/stack-nudge-update-runner.sh" + let runnerScript = Self.makeRunnerScript() + try? runnerScript.write(toFile: runnerPath, + atomically: true, encoding: .utf8) + _ = chmod(runnerPath, 0o755) + + startTailing() + + // Spawn the runner detached via Python's os.setsid + execvp so the + // child process gets its own session and won't be torn down when + // launchd unloads the panel job mid-update. + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/env") + // Fork before setsid: Foundation.Process places the spawned child + // in its own process group as the leader, so calling setsid() on + // python directly raises EPERM. We fork once; the child (not a + // pgroup leader) can setsid + exec bash cleanly while the parent + // exits, fully detaching the runner from our session. + task.arguments = [ + "python3", "-c", + """ + import os, sys + pid = os.fork() + if pid == 0: + os.setsid() + os.execvp('bash', ['bash'] + sys.argv[1:]) + else: + os._exit(0) + """, + runnerPath, + ] + task.standardInput = FileHandle.nullDevice + task.standardOutput = FileHandle.nullDevice + // Diagnostic: capture stderr to a file so silent Python errors + // (e.g. PermissionError from setsid()) become visible. Inspect with + // `cat /tmp/stack-nudge-update-spawn.err` after a failed run. + let stderrPath = "/tmp/stack-nudge-update-spawn.err" + try? FileManager.default.removeItem(atPath: stderrPath) + FileManager.default.createFile(atPath: stderrPath, contents: nil) + if let stderrHandle = FileHandle(forWritingAtPath: stderrPath) { + task.standardError = stderrHandle + } else { + task.standardError = FileHandle.nullDevice + } + do { + try task.run() + } catch { + DispatchQueue.main.async { + nav.updaterPhase = .failed + nav.updaterLog = "Failed to start updater: \(error.localizedDescription)" + } + } + } + + // Build the bash runner. It clones the repo to a fresh tmp dir, runs + // install.sh, and writes a JSON status file at the end. Output and STAGE + // markers go through tee so we get both file persistence and live-tail + // visibility from the panel. + private static func makeRunnerScript() -> String { + let cloneURL = Self.cloneURL + let logPath = Self.logPath + let statusPath = Self.statusPath + return """ + #!/usr/bin/env bash + # stack-nudge auto-updater runner. Spawned in a detached session by + # Updater.swift; survives the pkill install.sh runs on the panel. + set -o pipefail + LOG=\(logPath) + STATUS=\(statusPath) + WORK=$(mktemp -d -t stack-nudge-update) + trap 'rm -rf "$WORK"' EXIT + + write_status() { + local state="$1" version="$2" error_message="$3" + python3 - "$STATUS" "$state" "$version" "$error_message" <<'PY' + import json, sys + path, state, version, err = sys.argv[1:5] + d = {"state": state, "version": version} + if err: + d["error"] = err + with open(path, "w") as f: + json.dump(d, f) + PY + } + + run() { + echo "# STAGE: cloning" + echo "Cloning \(cloneURL) ..." + git clone --depth 1 \(cloneURL) "$WORK" 2>&1 || return 1 + local version + version=$(git -C "$WORK" describe --tags --abbrev=0 2>/dev/null || true) + echo "Cloned $(git -C "$WORK" rev-parse --short HEAD) (tag: ${version:-none})" + cd "$WORK" + bash ./install.sh 2>&1 || return 1 + write_status "success" "${version#v}" "" + return 0 + } + + run > "$LOG" 2>&1 + rc=$? + if [[ $rc -ne 0 ]]; then + # install.sh's failure already in the log; record the failed state + # for the post-swap panel to surface. + write_status "failed" "" "exit code $rc" + fi + exit $rc + + """ + } + + // MARK: - Live log tailing + + // Watches the runner log for writes and parses any new content. Each + // STAGE marker advances nav.updaterPhase; the full content backs the + // expandable "Show output" detail panel. + // + // Uses DispatchSource for filesystem events instead of polling so we + // get near-instant UI updates. Safe to call multiple times — any prior + // tail is torn down and offset is reset, so a re-triggered run() picks + // up from byte 0 of the fresh log file. + private func startTailing() { + // Tear down any prior tail before opening a fresh one. Without this, + // a second run() call would inherit the previous run's offset and + // skip all output (since the truncate makes the new file smaller + // than the saved offset). + tailHandle?.cancel() + tailHandle = nil + if tailFD >= 0 { close(tailFD); tailFD = -1 } + tailOffset = 0 + logBuffer = "" + + tailFD = open(Self.logPath, O_RDONLY) + guard tailFD >= 0 else { return } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: tailFD, + eventMask: [.write, .extend], + queue: .main + ) + source.setEventHandler { [weak self] in self?.consume() } + source.resume() + tailHandle = source + + // Read whatever's already there in case the first event fires + // after install.sh has already written. + consume() + } + + private func consume() { + guard tailFD >= 0 else { return } + let size = lseek(tailFD, 0, SEEK_END) + guard size > tailOffset else { return } + let toRead = Int(size - tailOffset) + _ = lseek(tailFD, tailOffset, SEEK_SET) + var data = Data(count: toRead) + let bytesRead = data.withUnsafeMutableBytes { buf -> Int in + guard let base = buf.baseAddress else { return 0 } + return read(tailFD, base, toRead) + } + if bytesRead > 0 { + tailOffset += off_t(bytesRead) + if let chunk = String(data: data.prefix(bytesRead), encoding: .utf8) { + logBuffer += chunk + processChunk(chunk) + } + } + } + + // Parse STAGE markers (preferred) and natural install.sh output lines + // (fallback) out of newly-arrived log content. The fallback path keeps + // the progress UI accurate when the cloned install.sh is from an older + // release that predates the STAGE markers — otherwise the UI would + // stick on .cloning until the runner finished. + private func processChunk(_ chunk: String) { + for line in chunk.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("# STAGE: ") { + let name = String(trimmed.dropFirst("# STAGE: ".count)) + if let phase = UpdatePhase(rawValue: name) { advance(to: phase) } + } else if let phase = Self.heuristicPhase(for: trimmed) { + advance(to: phase) + } + } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.nav?.updaterLog = self.logBuffer + } + } + + // Only ever moves forward — guards against an out-of-order line bumping + // the phase backwards (e.g. seeing the runner's older "Cloning..." echo + // after install.sh has already advanced us). When .done is reached for + // the first time, schedules a graceful self-quit so the freshly-installed + // bundle (relaunched by launchd) takes over without two panels lingering. + private func advance(to phase: UpdatePhase) { + DispatchQueue.main.async { [weak self] in + guard let nav = self?.nav else { return } + guard phase.step >= nav.updaterPhase.step else { return } + let firstTimeReachingDone = (phase == .done && nav.updaterPhase != .done) + nav.updaterPhase = phase + if firstTimeReachingDone { + self?.scheduleAutoQuit() + } + } + } + + // Quit ~2s after the install finishes so the user can read the "Done" + // confirmation before the panel disappears. install.sh's launchctl + // reload will then own the newly-installed binary's lifecycle. + private func scheduleAutoQuit() { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + NSApp.terminate(nil) + } + } + + // Recognise canonical install.sh output lines as phase markers. Order + // matters: more specific matches first so "Done!" doesn't get classified + // as something else. Used only when explicit STAGE markers are absent. + private static func heuristicPhase(for line: String) -> UpdatePhase? { + if line.hasPrefix("Done!") { return .done } + if line.contains("registered as launchd agent") { return .launchd } + if line.hasPrefix("Setting up voice engine") { return .venv } + if line.hasPrefix("Building stack-nudge") { return .building } + if line.hasPrefix("Installing stack-nudge") { return .building } + if line.hasPrefix("Detected ") { return .hooks } + return nil + } + + // MARK: - Post-launch status pickup + + // Called from PanelController.applicationDidFinishLaunching to read any + // status file the runner left behind during the previous panel's death + // and surface a result toast. The file is consumed on read. + static func consumePostUpdateStatus() -> (state: String, version: String, error: String?)? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: statusPath)), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] + else { return nil } + try? FileManager.default.removeItem(atPath: statusPath) + let state = json["state"] ?? "" + let version = json["version"] ?? "" + let error = json["error"] + return (state, version, error) + } +} + +// MARK: - Confirmation view + +// Shown when the user clicks the "Update available" row before the install +// actually starts. Surfaces the release notes (or a graceful fallback when +// the API is unreachable) so the user knows what they're about to install. +struct UpdateConfirmView: View { + + @ObservedObject var nav: PanelNav + let onCancel: () -> Void + let onConfirm: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + header + Divider().opacity(0.4) + notes + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(ThinScrollers()) + } + + PageFooter { + FooterHint(label: "Update now", keys: ["⏎"], primary: true) + FooterDivider() + FooterHint(label: "Cancel", keys: ["esc"]) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var header: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "arrow.down.circle.fill") + .font(.title2) + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text("Update stack-nudge") + .font(.headline) + if let version = nav.updateAvailable { + Text("v\(version)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + Spacer() + } + } + + @ViewBuilder + private var notes: some View { + Text("Release notes") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + if let body = nav.updateReleaseNotes, !body.isEmpty { + MarkdownNotesView(source: body) + } else { + Text("Release notes unavailable. The update will clone the latest source from GitHub and reinstall in place.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +// MARK: - Post-update view + +// Welcome-style screen shown automatically on the first launch after a +// successful update. Mirrors WelcomeView's layout (scrollable body + action +// bar) so the visual language stays consistent. Closes by Enter or "Got it" +// click, both of which set nav.mode = .events. +struct PostUpdateView: View { + + @ObservedObject var nav: PanelNav + let onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + header + if let notes = nav.postUpdateNotes, !notes.isEmpty { + Text("What's new") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.top, 4) + MarkdownNotesView(source: notes) + } else { + Text("Release notes unavailable. You can browse the full changelog on GitHub.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 18) + .background(ThinScrollers()) + } + actionBar + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "checkmark.seal.fill") + .font(.title3) + .foregroundStyle(Color.green) + VStack(alignment: .leading, spacing: 1) { + Text("Updated to v\(nav.postUpdateVersion ?? "?")") + .font(.title3.weight(.semibold)) + Text("stack-nudge has been upgraded in the background.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + } + + private var actionBar: some View { + HStack(spacing: 10) { + Spacer() + Button { + onDismiss() + } label: { + HStack(spacing: 6) { + Text("Got it").font(.subheadline.weight(.medium)) + KeyCapView(symbol: "⏎") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.accentColor.opacity(0.25)) + ) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background( + ZStack { + Color.primary.opacity(0.05) + Rectangle() + .fill(Color.primary.opacity(0.1)) + .frame(height: 0.5) + .frame(maxHeight: .infinity, alignment: .top) + } + ) + } +} + +// MARK: - Markdown notes renderer + +// Tiny line-by-line markdown renderer for release notes. Handles the four +// shapes release-please emits: ## / ### headings, bullet rows (* or -), +// blank lines as paragraph breaks, and paragraphs. Inline formatting +// (links, bold, italic, code) is passed through to SwiftUI's built-in +// AttributedString markdown parser via Text(.init(string)). +// +// We deliberately keep this minimal — no nested lists, no tables, no code +// blocks. release-please's auto-generated CHANGELOGs don't use them, and +// the panel is too narrow to render them well anyway. +struct MarkdownNotesView: View { + + let source: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + render(line) + } + } + .textSelection(.enabled) + } + + private enum Line { + case heading(level: Int, text: String) + case bullet(text: String) + case paragraph(text: String) + case blank + } + + private var lines: [Line] { + source.split(separator: "\n", omittingEmptySubsequences: false).map { raw in + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .blank } + if trimmed.hasPrefix("### ") { + return .heading(level: 3, text: String(trimmed.dropFirst(4))) + } + if trimmed.hasPrefix("## ") { + return .heading(level: 2, text: String(trimmed.dropFirst(3))) + } + if trimmed.hasPrefix("# ") { + return .heading(level: 1, text: String(trimmed.dropFirst(2))) + } + if trimmed.hasPrefix("* ") || trimmed.hasPrefix("- ") { + return .bullet(text: String(trimmed.dropFirst(2))) + } + return .paragraph(text: trimmed) + } + } + + @ViewBuilder + private func render(_ line: Line) -> some View { + switch line { + case .blank: + Color.clear.frame(height: 2) + case .heading(let level, let text): + Text(attributed(text)) + .font(headingFont(for: level)) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, level == 2 ? 4 : 0) + case .bullet(let text): + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("•").foregroundStyle(.secondary) + Text(attributed(text)) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + .font(.callout) + case .paragraph(let text): + Text(attributed(text)) + .font(.callout) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + } + + // Heading sizes scaled for the 420px-wide panel — h2 is "subheadline + // bold", h3 is "footnote bold". Anything bigger looks shouty. + private func headingFont(for level: Int) -> Font { + switch level { + case 1: return .headline + case 2: return .subheadline.weight(.semibold) + default: return .footnote.weight(.semibold) + } + } + + // Parse a single line's inline markdown (links, bold, italic, code) + // into an AttributedString. Falls back to a plain string on error. + private func attributed(_ source: String) -> AttributedString { + if let attr = try? AttributedString(markdown: source) { + return attr + } + return AttributedString(source) + } +} + +// MARK: - Updating view + +// Shown while install.sh is running. Top half: spinner + current phase +// label + step counter. Bottom half: a disclosure that reveals the raw +// install log (toggled via "Show output"). +struct UpdatingView: View { + + @ObservedObject var nav: PanelNav + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 14) { + phaseHeader + progressBar + detailDisclosure + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + PageFooter { + if nav.updaterPhase == .failed { + FooterHint(label: "Close", keys: ["esc"]) + } else { + FooterHint(label: "Don't quit stack-nudge during update", keys: []) + } + } + } + } + + private var phaseHeader: some View { + HStack(alignment: .center, spacing: 10) { + if nav.updaterPhase == .failed { + Image(systemName: "exclamationmark.triangle.fill") + .font(.title2) + .foregroundStyle(.red) + } else if nav.updaterPhase == .done { + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .foregroundStyle(.green) + } else { + ProgressView() + .controlSize(.small) + } + Text(nav.updaterPhase.label) + .font(.headline) + Spacer() + } + } + + private var progressBar: some View { + let fraction = Double(nav.updaterPhase.step) / Double(UpdatePhase.totalSteps) + return ProgressView(value: fraction) + .tint(nav.updaterPhase == .failed ? .red : .accentColor) + } + + @ViewBuilder + private var detailDisclosure: some View { + DisclosureGroup(isExpanded: $nav.updaterShowLog) { + ScrollView { + Text(nav.updaterLog.isEmpty ? "Waiting for output…" : nav.updaterLog) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(8) + } + .frame(maxHeight: 140) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.05)) + ) + } label: { + Text("Show output") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} +