Skip to content

Commit 38f60f4

Browse files
v0.9.0: swrag-helper.app bundle + native notifications
* Convert helper to a code-signed `.app` bundle. macOS TCC tracks raw binaries by absolute-path + checksum — both change on every `brew upgrade` because the materialised cache path encodes the tarball size. A `.app` is tracked by `CFBundleIdentifier` + ad-hoc-sign checksum, so Screen Recording / Microphone / Notifications grants survive upgrades. Bundle identity: `ai.swrag.helper`. Build pipeline lipo's arm64 + x86_64 into `Contents/MacOS/swrag-helper`, copies `Info.plist`, signs with `codesign --sign -`, then tars to `vendor/swrag-helper.app.tar`. * New `notify` subcommand in the helper, backed by `UNUserNotificationCenter`. Posts a banner with the requested action buttons; prints the chosen action lowercased on stdout (or `timeout` on banner expiry); exits non-zero with a clear stderr message if authorization is denied / not-yet-granted. Run-loop driven, no Dock icon (LSUIElement=true). Fail-fast on auth so the daemon never deadlocks waiting for a user response. * Replace the `osascript display dialog` modal start-recording popup with a native banner via `fireStartRecordingNotification`. Non-modal (no focus theft), Notification-Center-persistent if missed, honors Focus / Do Not Disturb. Same daemon-visible contract: `"record" | "skip" | "timeout"`. Helper failures (auth denied / spawn / bad stdout) degrade to `"timeout"` so the daemon never crashes on a notification-path failure. * TS materialisation switched from single-file embed to tarball embed + extract. The tar is `with { type: "file" }`-imported, extracted to a stable per-user cache dir on first call, the resulting bundle path stays the same across upgrades (this is the bit TCC's ad-hoc heuristics care about). Legacy v0.8.x raw-binary cache entries are swept on materialisation. * `permissions-check` gains a fourth permission (`notifications`), surfaced through to `swrag doctor`. Includes `provisional` as a fifth state (Apple's silent-opt-in notification mode). * Tests: stub `fireNotification` instead of `osascript` for the start-recording path. Real-binary notify smoke runs to a short timeout and asserts the stdout shape (skipped via `SWRAG_SKIP_NOTIFY_SMOKE=1` for environments where auth state is unknown). 374 tests pass; pre-v0.8.0 modal-dialog parser tests removed (no longer relevant on the banner path).
1 parent 9b26f70 commit 38f60f4

20 files changed

Lines changed: 1203 additions & 247 deletions

File tree

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ typings/
6060
# lives in `swift-helper/`; the binary is rebuilt fresh on every release
6161
# by `scripts/build-swift-helper.sh` (called from `scripts/build.ts`).
6262
/swift-helper/.build/
63+
# The Swift helper .app bundle + its tarball are build artifacts.
64+
# Build via `bash scripts/build-swift-helper.sh`.
65+
/vendor/swrag-helper.app/
66+
/vendor/swrag-helper.app.tar
67+
# Legacy raw-binary path from v0.8.x — the build script removes it
68+
# automatically, but list it here so a stale checkout doesn't leak
69+
# the artifact back into commits.
6370
/vendor/swrag-helper-darwin-universal
6471

6572
# Local-only Homebrew formula used for testing the build pipeline against

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "superwhisper-rag",
3-
"version": "0.8.0",
3+
"version": "0.9.0",
44
"description": "Local SQL archive for Super Whisper dictation history with full-text + semantic search.",
55
"type": "module",
66
"private": true,

scripts/build-swift-helper.sh

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,43 @@
11
#!/usr/bin/env bash
2-
# Build the Swift helper for arm64 + x86_64 and lipo into a universal
3-
# Mach-O at vendor/swrag-helper-darwin-universal.
2+
# Build the Swift helper for arm64 + x86_64 and assemble a code-signed
3+
# .app bundle at vendor/swrag-helper.app/. Also emit a deterministic
4+
# tarball of the bundle at vendor/swrag-helper.app.tar — the compiled
5+
# swrag CLI embeds the tarball via `with { type: "file" }` and
6+
# extracts it to a per-user cache dir on first use.
47
#
5-
# Idempotent: re-running with no source changes is a near-noop because
6-
# SPM caches builds per-arch. Fail clearly when the swift toolchain is
7-
# missing — Apple's Command Line Tools provide it; we don't fall back
8-
# to a brittle "install Xcode" path.
8+
# Why a bundle?
99
#
10-
# Layout:
10+
# * macOS TCC tracks raw binaries by (absolute path + checksum).
11+
# Both change on every brew upgrade because the per-user cache
12+
# dir suffix encodes the file size. Result: Screen Recording /
13+
# Microphone grants are revoked on every release.
14+
#
15+
# * A code-signed .app is tracked by (bundle identifier + sign
16+
# checksum). Ad-hoc sign with `--sign -` is sufficient for TCC
17+
# persistence; we don't need (and can't economically afford) a
18+
# Developer ID certificate.
19+
#
20+
# * UNUserNotificationCenter (used by the `notify` subcommand)
21+
# refuses to register a delegate or post alerts outside of a
22+
# real bundle. The .app is a hard prerequisite for the native
23+
# start-recording banner.
24+
#
25+
# Idempotent: re-running with no source changes is fast because SPM
26+
# caches builds per-arch.
27+
#
28+
# Layout produced:
1129
# swift-helper/.build/{arm64-apple-macosx,x86_64-apple-macosx}/release/SwragHelper
1230
# ↓ lipo
13-
# vendor/swrag-helper-darwin-universal
31+
# vendor/swrag-helper.app/Contents/MacOS/swrag-helper (universal)
32+
# vendor/swrag-helper.app/Contents/Info.plist (verbatim copy)
33+
# vendor/swrag-helper.app.tar (tarball of .app)
1434
set -euo pipefail
1535

1636
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
1737
PKG_DIR="$REPO_ROOT/swift-helper"
1838
VENDOR_DIR="$REPO_ROOT/vendor"
19-
OUT="$VENDOR_DIR/swrag-helper-darwin-universal"
39+
APP_DIR="$VENDOR_DIR/swrag-helper.app"
40+
APP_TAR="$VENDOR_DIR/swrag-helper.app.tar"
2041

2142
if ! command -v swift >/dev/null 2>&1; then
2243
echo "[swift-helper] error: 'swift' not found on PATH." >&2
@@ -44,11 +65,54 @@ if [[ ! -x "$X64_BIN" ]]; then
4465
exit 1
4566
fi
4667

47-
echo "[swift-helper] lipo into $OUT"
48-
lipo -create -output "$OUT" "$ARM_BIN" "$X64_BIN"
49-
chmod +x "$OUT"
68+
# Assemble bundle from scratch. We do `rm -rf` rather than overwriting
69+
# in place so a previous-run with different layout never lingers.
70+
rm -rf "$APP_DIR"
71+
mkdir -p "$APP_DIR/Contents/MacOS"
72+
73+
echo "[swift-helper] lipo into $APP_DIR/Contents/MacOS/swrag-helper"
74+
lipo -create -output "$APP_DIR/Contents/MacOS/swrag-helper" "$ARM_BIN" "$X64_BIN"
75+
chmod +x "$APP_DIR/Contents/MacOS/swrag-helper"
76+
77+
cp "$PKG_DIR/Resources/Info.plist" "$APP_DIR/Contents/Info.plist"
78+
79+
# Ad-hoc sign. `--force` lets us re-sign over the existing identity
80+
# every build (the bundle is fresh, but defensive); `--deep` walks
81+
# nested executables — we only have one, but future-proofs against
82+
# adding helper sub-binaries. `--sign -` is the magic ad-hoc identity:
83+
# no Developer ID required, but produces a stable signature checksum
84+
# tied to the binary contents + Info.plist that TCC uses to identify
85+
# the bundle across upgrades.
86+
echo "[swift-helper] codesign --sign - $APP_DIR"
87+
codesign --force --deep --sign - "$APP_DIR"
88+
codesign --verify --deep --strict "$APP_DIR" 2>/dev/null || {
89+
echo "[swift-helper] error: codesign verification failed" >&2
90+
exit 1
91+
}
92+
93+
# Tarball the bundle for embedding in the swrag CLI binary. We use
94+
# `-C` so paths in the tarball start at `swrag-helper.app/...` (not
95+
# `vendor/swrag-helper.app/...`), which keeps extraction simple on
96+
# the runtime side. Plain tar (not gzip) — the swrag CLI is already
97+
# inside a gzipped release tarball, so an inner gzip layer would
98+
# just waste CPU.
99+
echo "[swift-helper] tar -> $APP_TAR"
100+
( cd "$VENDOR_DIR" && tar -cf "$APP_TAR" swrag-helper.app )
101+
102+
# Clean up the legacy raw binary path from v0.8.x so a partial-state
103+
# checkout (or a tap formula that still points at the old path) fails
104+
# loudly instead of silently running the previous version.
105+
LEGACY_RAW="$VENDOR_DIR/swrag-helper-darwin-universal"
106+
if [[ -e "$LEGACY_RAW" ]]; then
107+
echo "[swift-helper] removing legacy raw binary at $LEGACY_RAW"
108+
rm -f "$LEGACY_RAW"
109+
fi
50110

51-
size=$(stat -f%z "$OUT")
52-
echo "[swift-helper] wrote $OUT ($size bytes)"
111+
app_size=$(du -sh "$APP_DIR" | awk '{print $1}')
112+
tar_size=$(stat -f%z "$APP_TAR")
113+
echo "[swift-helper] bundle: $APP_DIR ($app_size on disk)"
114+
echo "[swift-helper] tar: $APP_TAR ($tar_size bytes)"
53115
echo "[swift-helper] archs:"
54-
lipo -info "$OUT"
116+
lipo -info "$APP_DIR/Contents/MacOS/swrag-helper"
117+
echo "[swift-helper] codesign:"
118+
codesign -dvv "$APP_DIR" 2>&1 | sed 's/^/ /'

scripts/build.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ async function main() {
4545

4646
// The Swift helper is built locally (no npm package to fetch from).
4747
// We always run the build script — it's idempotent and SPM's cache
48-
// makes a no-source-change rebuild near-instant.
48+
// makes a no-source-change rebuild near-instant. Starting in v0.9.0
49+
// the script produces a code-signed .app bundle plus a tarball; the
50+
// tarball is what gets embedded into the swrag CLI binary.
4951
console.log("[build] building swift helper");
5052
const helper = Bun.spawnSync({
5153
cmd: ["bash", "scripts/build-swift-helper.sh"],
@@ -61,9 +63,13 @@ async function main() {
6163
"`xcode-select --install`.",
6264
);
6365
}
64-
const helperPath = join(ROOT, "vendor", "swrag-helper-darwin-universal");
65-
if (!existsSync(helperPath)) {
66-
throw new Error(`swift helper missing after build: ${helperPath}`);
66+
const helperTar = join(ROOT, "vendor", "swrag-helper.app.tar");
67+
const helperApp = join(ROOT, "vendor", "swrag-helper.app");
68+
if (!existsSync(helperTar)) {
69+
throw new Error(`swift helper tarball missing after build: ${helperTar}`);
70+
}
71+
if (!existsSync(helperApp)) {
72+
throw new Error(`swift helper bundle missing after build: ${helperApp}`);
6773
}
6874

6975
if (existsSync(DIST)) rmSync(DIST, { recursive: true, force: true });

src/commands/doctor.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,11 @@ const PERM_LABELS = {
193193
granted: "granted",
194194
denied: "DENIED",
195195
not_determined: "not_determined",
196+
provisional: "provisional",
196197
} as const;
197198

198199
function buildPermissionsCheck(perms: Permissions | null): Check {
199-
const name = "macOS permissions (mic + screen + automation)";
200+
const name = "macOS permissions (mic + screen + automation + notifications)";
200201
if (perms == null) {
201202
return {
202203
name,
@@ -207,6 +208,11 @@ function buildPermissionsCheck(perms: Permissions | null): Check {
207208
}
208209
const mic = PERM_LABELS[perms.microphone];
209210
const screen = PERM_LABELS[perms.screen_recording];
211+
// Notifications: `provisional` is treated as "ok-ish" — it's
212+
// Apple's silent-opt-in mode where alerts go straight to
213+
// Notification Center without prompting. We still display the
214+
// status verbatim so the user knows where they're at.
215+
const notif = PERM_LABELS[perms.notifications];
210216
const automationEntries = Object.entries(perms.automation);
211217
const automationGranted = automationEntries.filter(([, v]) => v === "granted").length;
212218
const automationDenied = automationEntries
@@ -218,15 +224,20 @@ function buildPermissionsCheck(perms: Permissions | null): Check {
218224
: automationDenied.length > 0
219225
? `automation=${automationGranted}/${automationEntries.length} granted (denied: ${automationDenied.join(", ")})`
220226
: `automation=${automationGranted}/${automationEntries.length} granted`;
221-
const detail = `mic=${mic}, screen=${screen}, ${automationDetail}`;
227+
const detail = `mic=${mic}, screen=${screen}, notifications=${notif}, ${automationDetail}`;
222228
const anyDenied =
223-
perms.microphone === "denied" || perms.screen_recording === "denied" || automationDenied.length > 0;
229+
perms.microphone === "denied" ||
230+
perms.screen_recording === "denied" ||
231+
perms.notifications === "denied" ||
232+
automationDenied.length > 0;
224233
// Treat `not_determined` as soft-fail: the user hasn't been
225234
// prompted yet; the watcher will prompt on first use. We surface
226-
// it so the user knows they can warm them eagerly.
235+
// it so the user knows they can warm them eagerly. `provisional`
236+
// is the OS's silent-opt-in mode and doesn't need a prompt.
227237
const anyUndecided =
228238
perms.microphone === "not_determined" ||
229239
perms.screen_recording === "not_determined" ||
240+
perms.notifications === "not_determined" ||
230241
automationEntries.some(([, v]) => v === "not_determined");
231242
const ok = !anyDenied && !anyUndecided;
232243
return {

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const VERSION = "0.8.0";
1+
export const VERSION = "0.9.0";
22

33
/**
44
* Per-call embed timeout. With `keep_alive: "15m"` (our default — see

0 commit comments

Comments
 (0)