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
1 change: 1 addition & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ build_app "$APP" "stack-nudge" \
panel/Settings.swift \
panel/SessionStore.swift \
panel/Sessions.swift \
panel/Welcome.swift \
shared/AppActivator.swift \
-framework Foundation -framework AppKit -framework SwiftUI -framework Carbon \
-framework UserNotifications
Expand Down
45 changes: 44 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,28 @@ PLIST
}

if [[ "$(uname -s)" == "Darwin" ]]; then
# Rotate logs (>10 MB) before launchd starts writing to them again. We move
# rather than truncate so users can still inspect the previous session if a
# bug shows up post-upgrade.
rotate_log() {
local log="$1"
[[ ! -f "$log" ]] && return
local size
size=$(stat -f%z "$log" 2>/dev/null || echo 0)
if (( size > 10485760 )); then
mv -f "$log" "${log}.old"
fi
}
rotate_log "${INSTALL_DIR}/daemon.log"
rotate_log "${INSTALL_DIR}/app.log"

# 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
# binary path so we don't reap unrelated processes.
pkill -f "Applications/stack-nudge\.app/Contents/MacOS/stack-nudge$" 2>/dev/null || true
pkill -f "venv/bin/stackvox serve$" 2>/dev/null || true

register_launchd_agent \
"com.stackonehq.stack-nudge-daemon" \
"always" \
Expand Down Expand Up @@ -277,9 +299,30 @@ fi
echo ""
echo "Done! Hooks are wired up."
echo ""
echo "Config: edit ~/.stack-nudge/config to customise behaviour."
echo " ┌──────────────────────────────────────────────┐"
echo " │ Press ⌘ + ⌥ + N to open the stack-nudge │"
echo " │ panel — events, sessions, and settings. │"
echo " └──────────────────────────────────────────────┘"
echo ""
echo "macOS may ask for notification + accessibility permissions on first launch."
echo "Grant them so banners appear and 'Allow' on permission nudges can press Enter."
echo ""
echo "Config: edit ~/.stack-nudge/config (or use the Settings tab in the panel)."
echo " STACKNUDGE_VOICE=true — speak notifications aloud"
echo " STACKNUDGE_ACTIVATE_IMMEDIATELY=true — focus your editor without clicking"
echo " STACKNUDGE_BANNER=false — suppress macOS banner notifications"
echo " STACKNUDGE_PANEL_HOTKEY=cmd+opt+n — global hotkey for the floating panel"
echo ""
echo "To uninstall, run: ./uninstall.sh"

# Fire a welcome notification so the user immediately sees what a nudge
# looks like AND macOS prompts for notification permission now (during
# install) rather than ambushing them mid-task later. Briefly wait for
# the launchd-managed app to come up before posting.
if [[ "$(uname -s)" == "Darwin" ]]; then
for _ in 1 2 3 4 5 6 7 8; do
[[ -S "$INSTALL_DIR/panel.sock" ]] && break
sleep 0.25
done
"$NOTIFY" stack-nudge welcome >/dev/null 2>&1 &
fi
11 changes: 10 additions & 1 deletion notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,11 @@ notify_macos() {

# Suppress banner only if the exact source window is currently frontmost.
# Gated on STACKNUDGE_MUTE_WHEN_FOCUSED — set to false to always notify
# regardless of which window has focus.
# regardless of which window has focus. The welcome event always fires
# (post-install confirmation must reach the user even though they're
# staring at the install terminal at that moment).
local mute_when_focused="${STACKNUDGE_MUTE_WHEN_FOCUSED:-true}"
[[ "${EVENT}" == "welcome" ]] && mute_when_focused="false"
if [[ "$mute_when_focused" == "true" ]]; then
local frontmost_id
frontmost_id=$(osascript -e "id of app (path to frontmost application as text)" 2>/dev/null)
Expand Down Expand Up @@ -546,6 +549,12 @@ case "$OS" in
voice_msg=$(voice_phrase_for permission)
notify_macos "$TITLE" "${ctx:-Waiting for your approval}" "$SOUND_PERMISSION" "$voice_msg"
;;
welcome)
# Fired once by install.sh so the user sees what a notification
# looks like and macOS prompts for notification permission while
# they're still in the install terminal, not mid-work later.
notify_macos "stack-nudge" "You're all set. Press ⌘⌥N to open the panel." "$SOUND_STOP" ""
;;
*)
voice_msg=$(voice_phrase_for stop)
notify_macos "$TITLE" "Done" "$SOUND_STOP" "$voice_msg"
Expand Down
66 changes: 58 additions & 8 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ private enum KeyCode {
static let rightArrow: UInt16 = 124
static let returnKey: UInt16 = 36
static let numpadEnter: UInt16 = 76
static let space: UInt16 = 49
static let tab: UInt16 = 48
static let oKey: UInt16 = 31
static let rKey: UInt16 = 15
Expand Down Expand Up @@ -65,15 +66,23 @@ struct PanelContentView: View {
@ObservedObject var sessions: SessionStore
@ObservedObject var nav: PanelNav

let onGrantPermissions: () -> Void

var body: some View {
VStack(alignment: .leading, spacing: 0) {
tabStrip
Divider().opacity(0.4)
if !nav.welcomed {
WelcomeView(nav: nav,
hotkeyDisplay: nav.hotkeyDisplay,
onGrantPermissions: onGrantPermissions)
} else {
tabStrip
Divider().opacity(0.4)

switch nav.mode {
case .events: eventsBody
case .sessions: SessionsView(store: sessions)
case .settings: SettingsView(nav: nav)
switch nav.mode {
case .events: eventsBody
case .sessions: SessionsView(store: sessions)
case .settings: SettingsView(nav: nav)
}
}
}
}
Expand Down Expand Up @@ -285,7 +294,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
blur.layer?.cornerRadius = 12
blur.layer?.masksToBounds = true

let host = NSHostingView(rootView: PanelContentView(store: store, sessions: sessions, nav: nav))
let host = NSHostingView(rootView: PanelContentView(
store: store, sessions: sessions, nav: nav,
onGrantPermissions: { [weak self] in self?.handleGrantPermissions() }
))
host.translatesAutoresizingMaskIntoConstraints = false
blur.addSubview(host)
NSLayoutConstraint.activate([
Expand Down Expand Up @@ -325,6 +337,18 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
store.onAppend = { [weak self] event in self?.postBannerIfNeeded(event) }
nav.loadFromConfig() // populate panelPinned + other live values up-front

// 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,
// not auto-fired.
if !nav.welcomed {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
guard let self, !self.nav.welcomed else { return }
NSApp.activate(ignoringOtherApps: true)
self.panel.makeKeyAndOrderFront(nil)
}
}

// Auto-hide when the panel loses key focus, if pin is off.
// Detect "click outside" without polling — NSWindow fires this when
// another window or app takes focus.
Expand All @@ -336,6 +360,15 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
)
}

// Triggered from the welcome screen's "Grant permissions" button. Opens
// the in-app Permissions window — it shows live status for Notifications,
// Accessibility, and Automation, with per-row "Prompt" and "Settings"
// buttons so the user can drive each grant without surprise modal dialogs
// covering anything.
private func handleGrantPermissions() {
showPermissions()
}

@objc private func panelDidResignKey(_ notification: Notification) {
guard !nav.panelPinned, panel.isVisible else { return }
hidePanel()
Expand Down Expand Up @@ -547,6 +580,23 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
let blockingMods: NSEvent.ModifierFlags = [.control, .option]
let cmdOnly = mods.intersection([.command, .control, .option, .shift]) == .command

// Welcome view: only Enter (dismiss) and Esc (hide) are meaningful.
// Swallow everything else so the user can't navigate to a non-existent
// tab strip while welcome is showing.
if !nav.welcomed {
let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty
guard plain else { return true }
switch event.keyCode {
case KeyCode.returnKey, KeyCode.numpadEnter:
nav.dismissWelcome()
case KeyCode.escape:
hidePanel()
default:
break
}
return true
}

// While recording a hotkey, capture the next combo. Arrow keys / Tab
// bail out gracefully — otherwise users who entered record mode by
// mistake would be stuck on row 0 with all their keypresses swallowed.
Expand Down Expand Up @@ -645,7 +695,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
nav.cycleBackward()
case KeyCode.rightArrow where plain:
nav.cycleForward()
case KeyCode.returnKey, KeyCode.numpadEnter:
case KeyCode.returnKey, KeyCode.numpadEnter, KeyCode.space:
guard plain else { return false }
nav.activate()
default:
Expand Down
11 changes: 11 additions & 0 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ final class PanelNav: ObservableObject {
@Published var voiceEnabled: Bool = false
@Published var muteWhenFocused: Bool = true
@Published var panelPinned: Bool = true
@Published var welcomed: Bool = true // default true; install creates a fresh config without it set
@Published var soundStop: String = "Glass"
@Published var soundPermission: String = "Ping"
@Published var voice: String = "af_aoede"
Expand Down Expand Up @@ -96,6 +97,9 @@ final class PanelNav: ObservableObject {
voiceEnabled = ConfigFile.bool(config, "STACKNUDGE_VOICE", default: false)
muteWhenFocused = ConfigFile.bool(config, "STACKNUDGE_MUTE_WHEN_FOCUSED", default: true)
panelPinned = ConfigFile.bool(config, "STACKNUDGE_PANEL_PIN", default: true)
// Default false on first run so the welcome view shows. We also write
// STACKNUDGE_WELCOMED=true the first time the user dismisses it.
welcomed = ConfigFile.bool(config, "STACKNUDGE_WELCOMED", default: false)
soundStop = config["STACKNUDGE_SOUND_STOP"] ?? "Glass"
soundPermission = config["STACKNUDGE_SOUND_PERMISSION"] ?? "Ping"
voice = config["STACKNUDGE_VOICE_NAME"] ?? "af_aoede"
Expand Down Expand Up @@ -130,6 +134,13 @@ final class PanelNav: ObservableObject {
.filter { !$0.isEmpty && !$0.contains(" ") }
}

// MARK: - Welcome

func dismissWelcome() {
welcomed = true
ConfigFile.write(key: "STACKNUDGE_WELCOMED", value: "true")
}

// MARK: - Row movement

func selectNextRow() {
Expand Down
Loading