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
18 changes: 7 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,15 @@ jobs:
steps:
- uses: actions/checkout@v6

- name: build both .app bundles
- name: build stack-nudge.app
run: ./build.sh ${{ matrix.arch }}

- name: verify executables exist + are correct arch
- name: verify executable exists + is correct arch
run: |
set -e
for app in build/stack-nudge.app build/stack-nudge-panel.app; do
bin=$(ls "$app/Contents/MacOS/")
file "$app/Contents/MacOS/$bin"
file "$app/Contents/MacOS/$bin" | grep -q "${{ matrix.arch }}"
done
bin=$(ls build/stack-nudge.app/Contents/MacOS/)
file "build/stack-nudge.app/Contents/MacOS/$bin"
file "build/stack-nudge.app/Contents/MacOS/$bin" | grep -q "${{ matrix.arch }}"

- name: verify Info.plists are valid
run: |
plutil -lint notifier/Info.plist
plutil -lint panel/Info.plist
- name: verify Info.plist is valid
run: plutil -lint panel/Info.plist
18 changes: 9 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@

.DEFAULT_GOAL := help

PANEL_APP := $(HOME)/Applications/stack-nudge-panel.app
PANEL_LABEL := com.stackonehq.stack-nudge-panel
APP := $(HOME)/Applications/stack-nudge.app
APP_LABEL := com.stackonehq.stack-nudge
BUILD_LOG := /tmp/stack-nudge-dev.log
WATCH_DIRS := panel shared notifier notify.sh phrases

.PHONY: help
help:
@echo "stack-nudge targets:"
@echo " make build build both .app bundles into build/"
@echo " make build build stack-nudge.app into build/"
@echo " make install full install (build + copy + register hooks + launchd)"
@echo " make uninstall remove apps, hooks, launchd agents, ~/.stack-nudge/"
@echo " make reload rebuild + replace installed panel + bounce the daemon"
@echo " make uninstall remove app, hooks, launchd agents, ~/.stack-nudge/"
@echo " make reload rebuild + replace installed app + bounce the daemon"
@echo " make dev watch sources; auto-reload on change (ctrl-c to stop)"
@echo " make clean remove build/ output"

Expand All @@ -34,7 +34,7 @@ uninstall:
clean:
@rm -rf build

# One-shot dev cycle: rebuild, reinstall the panel.app, refresh notify.sh in
# One-shot dev cycle: rebuild, reinstall the app, refresh notify.sh in
# ~/.stack-nudge so hook-side changes propagate, kickstart the daemon.
# Build output goes to $(BUILD_LOG); on failure, last 20 lines tail to stderr.
.PHONY: reload
Expand All @@ -46,16 +46,16 @@ reload:
tail -20 $(BUILD_LOG) | sed 's/^/ /'; \
exit 1; \
fi; \
rm -rf "$(PANEL_APP)"; \
cp -R build/stack-nudge-panel.app "$(PANEL_APP)"; \
rm -rf "$(APP)"; \
cp -R build/stack-nudge.app "$(APP)"; \
if [ -d "$$HOME/.stack-nudge" ]; then \
cp notify.sh "$$HOME/.stack-nudge/notify.sh"; \
if [ -d phrases ]; then \
rm -rf "$$HOME/.stack-nudge/phrases"; \
cp -R phrases "$$HOME/.stack-nudge/phrases"; \
fi; \
fi; \
launchctl kickstart -k "gui/$$(id -u)/$(PANEL_LABEL)" 2>/dev/null || true; \
launchctl kickstart -k "gui/$$(id -u)/$(APP_LABEL)" 2>/dev/null || true; \
printf 'reloaded\n'

# Watch loop. Polling-based (500 ms) — no fswatch / entr dependency. Uses a
Expand Down
22 changes: 7 additions & 15 deletions build.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
#!/usr/bin/env bash
# Builds stack-nudge.app and stack-nudge-panel.app
# Builds stack-nudge.app (single persistent binary: panel + banners + voice)
# Usage: ./build.sh [arm64|x86_64] (defaults to host arch)

set -e

ARCH="${1:-$(uname -m)}"
NOTIFIER_APP="build/stack-nudge.app"
PANEL_APP="build/stack-nudge-panel.app"
APP="build/stack-nudge.app"

build_app() {
local app="$1"
Expand Down Expand Up @@ -37,16 +36,7 @@ build_app() {
echo "Building stack-nudge ($ARCH)..."
rm -rf build

build_app "$NOTIFIER_APP" "stack-nudge" \
"notifier/Info.plist" "notifier/Icon.icns" "12.0" \
notifier/main.swift \
notifier/Config.swift \
notifier/Notifier.swift \
shared/AppActivator.swift \
-framework Foundation -framework AppKit
echo " Built $NOTIFIER_APP"

build_app "$PANEL_APP" "stack-nudge-panel" \
build_app "$APP" "stack-nudge" \
"panel/Info.plist" "notifier/Icon.icns" "13.0" \
panel/main.swift \
panel/Config.swift \
Expand All @@ -63,5 +53,7 @@ build_app "$PANEL_APP" "stack-nudge-panel" \
panel/SessionStore.swift \
panel/Sessions.swift \
shared/AppActivator.swift \
-framework Foundation -framework AppKit -framework SwiftUI -framework Carbon
echo " Built $PANEL_APP"
-framework Foundation -framework AppKit -framework SwiftUI -framework Carbon \
-framework UserNotifications
echo " Built $APP"
echo " Binary: $(file "$APP/Contents/MacOS/stack-nudge")"
43 changes: 19 additions & 24 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,15 @@ echo "Installing stack-nudge..."

mkdir -p "$INSTALL_DIR"

# Build and install the native app bundles (macOS click-to-focus banners + panel)
# Build and install the native app bundle (single persistent binary)
if [[ "$(uname -s)" == "Darwin" ]]; then
echo ""
echo "Building stack-nudge.app + stack-nudge-panel.app..."
echo "Building stack-nudge.app..."
bash "$SCRIPT_DIR/build.sh" >/dev/null
rm -rf "$HOME/Applications/stack-nudge.app"
rm -rf "$HOME/Applications/stack-nudge-panel.app"
cp -r "$SCRIPT_DIR/build/stack-nudge.app" "$HOME/Applications/stack-nudge.app"
cp -r "$SCRIPT_DIR/build/stack-nudge-panel.app" "$HOME/Applications/stack-nudge-panel.app"
echo " Installed stack-nudge.app -> ~/Applications/stack-nudge.app"
echo " Installed stack-nudge-panel.app -> ~/Applications/stack-nudge-panel.app"
rm -rf "$HOME/Applications/stack-nudge-panel.app" # clean up old panel binary
cp -r "$SCRIPT_DIR/build/stack-nudge.app" "$HOME/Applications/stack-nudge.app"
echo " Installed stack-nudge.app -> ~/Applications/stack-nudge.app"
fi

# Pick a Python ≥ 3.10 for the venv. stackvox requires it, but `python3` on
Expand Down Expand Up @@ -119,23 +117,20 @@ if [[ "$(uname -s)" == "Darwin" ]]; then
"${VENV}/bin/stackvox" "serve"
echo " Voice daemon registered as launchd agent (starts at login)"

# Panel launcher is config-aware: exits 0 when STACKNUDGE_PANEL isn't enabled.
# KeepAlive=on_crash lets launchd respect that exit and not loop.
PANEL_LAUNCHER="$INSTALL_DIR/panel-launcher.sh"
cat > "$PANEL_LAUNCHER" <<'LAUNCHER'
#!/usr/bin/env bash
[[ -f "$HOME/.stack-nudge/config" ]] && source "$HOME/.stack-nudge/config"
[[ "${STACKNUDGE_PANEL:-false}" != "true" ]] && exit 0
exec "$HOME/Applications/stack-nudge-panel.app/Contents/MacOS/stack-nudge-panel"
LAUNCHER
chmod +x "$PANEL_LAUNCHER"

# Single persistent app — always running, restarts on crash.
register_launchd_agent \
"com.stackonehq.stack-nudge-panel" \
"on_crash" \
"${INSTALL_DIR}/panel.log" \
"${PANEL_LAUNCHER}"
echo " Panel daemon registered as launchd agent (starts at login when STACKNUDGE_PANEL=true)"
"com.stackonehq.stack-nudge" \
"always" \
"${INSTALL_DIR}/app.log" \
"$HOME/Applications/stack-nudge.app/Contents/MacOS/stack-nudge"
echo " App registered as launchd agent (starts at login)"

# Remove old panel launchd agent if upgrading from two-binary setup
OLD_PANEL_PLIST="$HOME/Library/LaunchAgents/com.stackonehq.stack-nudge-panel.plist"
if [[ -f "$OLD_PANEL_PLIST" ]]; then
launchctl unload "$OLD_PANEL_PLIST" 2>/dev/null || true
rm -f "$OLD_PANEL_PLIST"
fi
fi

# Copy notify.sh and the phrase pools (sourced by notify.sh at runtime
Expand Down Expand Up @@ -238,6 +233,6 @@ echo ""
echo "Config: edit ~/.stack-nudge/config to customise behaviour."
echo " STACKNUDGE_VOICE=true — speak notifications aloud"
echo " STACKNUDGE_ACTIVATE_IMMEDIATELY=true — focus your editor without clicking"
echo " STACKNUDGE_PANEL=true — keyboard-native floating panel (cmd+shift+n)"
echo " STACKNUDGE_BANNER=false — suppress macOS banner notifications"
echo " STACKNUDGE_PANEL_HOTKEY=cmd+opt+n — global hotkey for the floating panel"
echo "To uninstall, run: ./uninstall.sh"
10 changes: 2 additions & 8 deletions notify.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,8 @@
# Default: true
#STACKNUDGE_BANNER=false

# Run the floating panel daemon. Press the hotkey to focus a movable
# window listing recent nudges, then act on them with the keyboard:
# Enter → approve permission / focus source editor
# O → focus source editor without approving
# ↑ ↓ → move selection
# Esc → hide panel
# Default: false
#STACKNUDGE_PANEL=true
# Note: STACKNUDGE_PANEL=true is no longer required — the panel is always
# available via the hotkey below. STACKNUDGE_PANEL=false is also no-op.

# Global hotkey that focuses the panel. Modifiers: cmd, shift, opt/alt, ctrl.
# Default: cmd+opt+n (avoids cmd+shift+n which most browsers bind to
Expand Down
84 changes: 12 additions & 72 deletions notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,6 @@ voice_permission_context() {
esac
}

# Set to "true" to bring your editor to focus immediately when the notification
# fires, instead of waiting for you to click it.
ACTIVATE_IMMEDIATELY="${STACKNUDGE_ACTIVATE_IMMEDIATELY:-false}"

# Set to "true" to speak notifications aloud via StackVox (offline TTS).
# Requires: pip install stackvox && stackvox serve
Expand Down Expand Up @@ -186,9 +183,6 @@ voice_phrase_for() {
printf "$template" "$repo"
}

# Banner and panel surfaces are independent; sound/voice always fire.
BANNER_ENABLED="${STACKNUDGE_BANNER:-true}"
PANEL_ENABLED="${STACKNUDGE_PANEL:-false}"
PANEL_SOCK="${HOME}/.stack-nudge/panel.sock"

# Pretty-print the agent name for the notification title
Expand Down Expand Up @@ -227,27 +221,12 @@ speak_notification() {
# own directory, and the repo build/ output (for in-tree development).
# Args: app-bundle-name (e.g. "stack-nudge.app")
# Echoes the first match, empty string if none found.
find_app_bundle() {
local name="$1"
for candidate in \
"$HOME/Applications/$name" \
"$(dirname "$0")/$name" \
"$(dirname "$0")/build/$name"; do
if [[ -d "$candidate" ]]; then
printf '%s' "$candidate"
return
fi
done
}

ensure_panel_running() {
[[ "${PANEL_ENABLED}" != "true" ]] && return
ensure_app_running() {
[[ -S "$PANEL_SOCK" ]] && return
local panel_app
panel_app=$(find_app_bundle "stack-nudge-panel.app")
[[ -z "$panel_app" ]] && return
# -g: launch in the background, don't bring the panel app to foreground
open -ga "$panel_app" 2>/dev/null
local app_path="$HOME/Applications/stack-nudge.app"
[[ ! -d "$app_path" ]] && return
# -g: launch in the background, don't steal focus from the editor
open -ga "$app_path" 2>/dev/null
for _ in 1 2 3 4 5 6 7 8 9 10; do
[[ -S "$PANEL_SOCK" ]] && return
sleep 0.1
Expand Down Expand Up @@ -289,8 +268,7 @@ walk_session_chain() {
# we have ~15 fields.
# Args: title message bundle_id window_title has_action(true|false)
post_to_panel() {
[[ "${PANEL_ENABLED}" != "true" ]] && return
ensure_panel_running
ensure_app_running
[[ ! -S "$PANEL_SOCK" ]] && return

walk_session_chain
Expand Down Expand Up @@ -434,58 +412,20 @@ notify_macos() {
local has_action="false"
[[ "${EVENT}" == "permission" ]] && has_action="true"

# Post first so the panel has the event queued by the time the sound plays.
# Backgrounded — the python3 cold-start (~50ms) shouldn't block the agent's hook.
# Post to the persistent app — it handles both the panel history and the
# UNUserNotification banner based on the user's config. Backgrounded so
# Python startup (~50ms) doesn't block the agent hook.
post_to_panel "${title}" "${message}" "${bundle_id}" "${project_name:-}" "${has_action}" &

# Voice "replaces" sound — when both are configured, the spoken message
# is the audible signal so we don't double-cue with a chime.
local effective_sound="$sound"
[[ "${VOICE_ENABLED}" == "true" ]] && effective_sound=""

if [[ "${BANNER_ENABLED}" == "true" ]]; then
fire_banner "$title" "$message" "$effective_sound" "$bundle_id" \
"${project_name:-}" "${win_title}" "${has_action}"
elif [[ "${VOICE_ENABLED}" != "true" ]]; then
# Sound fires independently via afplay — guaranteed even if macOS throttles
# or the app isn't running yet. Voice replaces the chime when enabled.
if [[ "${VOICE_ENABLED}" != "true" ]]; then
afplay "/System/Library/Sounds/${sound}.aiff" 2>/dev/null &
fi

speak_notification "${voice_message}"
}

fire_banner() {
local title="$1" message="$2" sound="$3" bundle_id="$4"
local project_name="$5" win_title="$6" has_action="$7"

local app_bundle
app_bundle=$(find_app_bundle "stack-nudge.app")

if [[ -z "$app_bundle" ]]; then
# Fallback path when stack-nudge.app isn't installed. `sound` is empty
# when voice is enabled — skip both the afplay chime and osascript's
# sound name so the user hears voice only.
if [[ -n "$sound" ]]; then
afplay "/System/Library/Sounds/${sound}.aiff" 2>/dev/null &
osascript -e "display notification \"${message}\" with title \"${title}\" sound name \"${sound}\"" 2>/dev/null
else
osascript -e "display notification \"${message}\" with title \"${title}\"" 2>/dev/null
fi
return
fi

local open_args=(
--args
--title "${title}" --message "${message}"
--sound "${sound}" --activate "${bundle_id}"
)
[[ "${ACTIVATE_IMMEDIATELY}" == "true" ]] && open_args+=(--activate-immediately)
[[ -n "$win_title" ]] && open_args+=(--window-title "${project_name}")
[[ -n "${VSCODE_IPC_HOOK_CLI}" ]] && open_args+=(--ipc-hook "${VSCODE_IPC_HOOK_CLI}")
open_args+=(--project-path "${PWD}")
[[ "${has_action}" == "true" ]] && open_args+=(--has-action-button)

open -a "$app_bundle" "${open_args[@]}"
}

play_linux() {
local sound_complete="/usr/share/sounds/freedesktop/stereo/complete.oga"
Expand Down
6 changes: 5 additions & 1 deletion panel/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import Foundation
// notify.sh shell-sources it; we just need the subset relevant to the panel.
struct PanelConfig {
var hotkeySpec: String = "cmd+opt+n"
var bannerEnabled: Bool = true
var activateImmediately: Bool = false

static func load() -> PanelConfig {
var config = PanelConfig()
Expand All @@ -20,7 +22,9 @@ struct PanelConfig {
let value = stripQuotes(String(line[line.index(after: eq)...])
.trimmingCharacters(in: .whitespaces))
switch key {
case "STACKNUDGE_PANEL_HOTKEY": config.hotkeySpec = value
case "STACKNUDGE_PANEL_HOTKEY": config.hotkeySpec = value
case "STACKNUDGE_BANNER": config.bannerEnabled = value.lowercased() != "false"
case "STACKNUDGE_ACTIVATE_IMMEDIATELY": config.activateImmediately = value.lowercased() == "true"
default: break
}
}
Expand Down
4 changes: 4 additions & 0 deletions panel/EventStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,16 @@ final class EventStore: ObservableObject {

private let maxEvents = 5

/// Called on main queue after each new event is inserted.
var onAppend: ((NudgeEvent) -> Void)?

func append(_ event: NudgeEvent) {
events.insert(event, at: 0)
if events.count > maxEvents {
events = Array(events.prefix(maxEvents))
}
if selectedID != event.id { selectedID = event.id }
onAppend?(event)
if ProcessInfo.processInfo.environment["STACKNUDGE_PANEL_DEBUG"] != nil {
FileHandle.standardError.write(Data(
"panel: received \(event.agent)/\(event.kind.rawValue): \(event.message)\n".utf8))
Expand Down
8 changes: 5 additions & 3 deletions panel/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.stackonehq.stack-nudge-panel</string>
<string>com.stackonehq.stack-nudge</string>
<key>CFBundleName</key>
<string>stack-nudge-panel</string>
<string>stack-nudge</string>
<key>CFBundleExecutable</key>
<string>stack-nudge-panel</string>
<string>stack-nudge</string>
<key>CFBundleIconFile</key>
<string>Icon</string>
<key>CFBundlePackageType</key>
Expand All @@ -20,6 +20,8 @@
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSUserNotificationAlertStyle</key>
<string>alert</string>
<key>NSAppleEventsUsageDescription</key>
<string>stack-nudge uses System Events to focus the correct window when you act on a notification.</string>
</dict>
Expand Down
Loading