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
2 changes: 2 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
5 changes: 5 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -297,6 +301,7 @@ if [[ "$installed_any" == "false" ]]; then
fi

echo ""
echo "# STAGE: done"
echo "Done! Hooks are wired up."
echo ""
echo " ┌──────────────────────────────────────────────┐"
Expand Down
136 changes: 134 additions & 2 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
}
}
Expand All @@ -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()

Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
59 changes: 55 additions & 4 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +27,8 @@ struct SettingsActions {
let checkPermissions: () -> Void
let openConfig: () -> Void
let editPhrases: () -> Void
let beginUpdate: () -> Void
let runUpdate: () -> Void
let quit: () -> Void
}

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
Loading