diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80a1a67..80955e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ concurrency: jobs: build: name: build - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 30 env: # See crates/stint-core/tests/config.rs — gates the one Keychain test. @@ -54,6 +54,14 @@ jobs: - name: cargo test run: cargo test --workspace -- --test-threads=1 + - name: Swift test (StintIntents framework) + working-directory: crates/stint-app/swift/StintIntents + run: xcodebuild -scheme StintIntents -destination 'platform=macOS' -derivedDataPath ./build/derived test + + - name: Swift test (StintWidget) + working-directory: crates/stint-app/swift/StintWidget + run: xcodebuild -scheme StintWidget -destination 'platform=macOS' -derivedDataPath ./build/derived test + - name: pnpm install (root workspace) run: pnpm install --frozen-lockfile @@ -68,7 +76,7 @@ jobs: coverage: name: coverage - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 25 env: STINT_SKIP_KEYCHAIN_TESTS: "1" diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 92a87f1..b9ddcc6 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -14,7 +14,7 @@ on: jobs: build: - runs-on: macos-14 + runs-on: macos-15 env: MACOSX_DEPLOYMENT_TARGET: "13.0" steps: @@ -160,6 +160,21 @@ jobs: cp target/universal-apple-darwin/release/stint "$APP/Contents/MacOS/stint" echo "APP_PATH=$APP" >> "$GITHUB_ENV" + - name: Relocate StintWidget.appex into Contents/PlugIns/ + run: | + # build.rs produces crates/stint-app/PlugIns/StintWidget.appex. + # Tauri's bundle.resources puts files under Contents/Resources/ + # but macOS WidgetKit only discovers widget extensions at + # Contents/PlugIns/. Move it into place + strip the duplicated + # Resources/PlugIns copy left behind by Tauri. + SRC="crates/stint-app/PlugIns/StintWidget.appex" + DEST="$APP_PATH/Contents/PlugIns/StintWidget.appex" + if [ ! -d "$SRC" ]; then echo "::error::appex source missing at $SRC"; exit 1; fi + mkdir -p "$(dirname "$DEST")" + rm -rf "$DEST" + cp -R "$SRC" "$DEST" + rm -rf "$APP_PATH/Contents/Resources/PlugIns" + - name: Smoke-test embedded version env: VERSION: ${{ inputs.version }} @@ -193,6 +208,14 @@ jobs: --entitlements crates/stint-app/entitlements.plist \ "$APP_PATH/Contents/MacOS/stint" + - name: Verify StintIntents framework present + run: | + # The framework is bundled but its App Intents stencil isn't + # consulted by Apple's indexer on the framework path anyway + # (the 6b deferral). Phase 6d replaces this with a real .appex. + FRAMEWORK="$APP_PATH/Contents/Frameworks/StintIntents.framework" + if [ ! -d "$FRAMEWORK" ]; then echo "::error::framework missing"; exit 1; fi + - name: Sign GUI binary + .app bundle (hardened runtime + entitlements) env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} @@ -201,6 +224,19 @@ jobs: # (which seals CodeResources including the CLI's hash). Avoid # --deep so the per-binary signatures we did individually # aren't overwritten. + # Sign embedded framework first (inner) so the wrapper seals it. + codesign --force --options runtime \ + --sign "$APPLE_SIGNING_IDENTITY" \ + "$APP_PATH/Contents/Frameworks/StintIntents.framework" + # Sign the widget appex so the bundle wrapper can seal it. The + # appex's binary already carries an ad-hoc signature from build.rs; + # this overwrites with the production identity. The sandbox + # entitlement is required — pluginkit refuses to register + # extensions without com.apple.security.app-sandbox = true. + codesign --force --options runtime \ + --sign "$APPLE_SIGNING_IDENTITY" \ + --entitlements crates/stint-app/swift/StintWidget/StintWidget.entitlements \ + "$APP_PATH/Contents/PlugIns/StintWidget.appex" codesign --force --options runtime \ --sign "$APPLE_SIGNING_IDENTITY" \ --entitlements crates/stint-app/entitlements.plist \ @@ -209,8 +245,10 @@ jobs: --sign "$APPLE_SIGNING_IDENTITY" \ --entitlements crates/stint-app/entitlements.plist \ "$APP_PATH" - codesign --verify --strict --verbose=2 "$APP_PATH" + codesign --verify --deep --strict --verbose=2 "$APP_PATH" codesign --verify --strict --verbose=2 "$APP_PATH/Contents/MacOS/stint" + codesign --verify --strict --verbose=2 "$APP_PATH/Contents/Frameworks/StintIntents.framework" + codesign --verify --strict --verbose=2 "$APP_PATH/Contents/PlugIns/StintWidget.appex" - name: Notarize .app env: diff --git a/.gitignore b/.gitignore index 4f505eb..6e6790d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /target/ **/*.rs.bk *.pdb +# llvm-cov / cargo-llvm-cov coverage instrumentation artifacts +*.profraw # Claude Code session-local files .claude/ @@ -61,3 +63,4 @@ node_modules/ # Image-generation scratch output (nano-banana skill) nanobanana-output/ +Frameworks/ diff --git a/CLAUDE.md b/CLAUDE.md index 29d5a0c..0343680 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,8 +232,11 @@ git checkout -b phase-2.5 | 3c | Solidtime down-sync | ✅ shipped (`phase-3c-complete`) | | 3.5 | Test coverage uplift across core / CLI / app / UI | ✅ shipped (`phase-3.5-complete`) | | 3d | Post-3b UX polish + sync resilience + in-app error surfacing (picker / calendar defaults / editable times / backdate / restart-from-entry / calendar undo / 4xx-abandon / adopt-on-overlap / SyncErrorBanner + coverage CI) | ✅ shipped (`phase-3d-complete`) | -| 4 | Distribution (Homebrew cask + signing + release CD) | planned | -| 5 | Documentation site (GitHub Pages) | planned | +| 4 | Distribution (Homebrew cask + signing + release CD) | ✅ shipped (`phase-4-complete`) | +| 5 | Documentation site (GitHub Pages) | ✅ shipped (`phase-5-complete`) | +| 6a | verbs façade + MCP + HTTP API + URL scheme + man page + skill installer | ✅ shipped (`phase-6a-complete`) | +| 6b | Spotlight + App Intents + Focus filter | ⚠️ **partial** (`phase-6b-complete`) — Spotlight indexing + tap-to-focus-entry shipped and working; Siri/Shortcuts.app/Focus-filter discovery deferred to a follow-up using an App Intents Extension. See `docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md` §1.5 | +| 6c | Raycast + Alfred + WidgetKit + idle detection | ✅ shipped (`phase-6c-complete`) | ## Gotchas / dev-environment notes diff --git a/Cargo.lock b/Cargo.lock index 213017d..c8772f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4889,9 +4889,11 @@ dependencies = [ "axum", "chrono", "dirs 5.0.1", + "libc", "semver", "serde", "serde_json", + "stint-app", "stint-core", "tauri", "tauri-build", @@ -4953,6 +4955,7 @@ dependencies = [ "chrono", "dirs 5.0.1", "keyring", + "libc", "oauth2", "pretty_assertions", "rand 0.8.6", diff --git a/README.md b/README.md index dece60c..7ea51ad 100644 --- a/README.md +++ b/README.md @@ -239,8 +239,11 @@ branch. | 3c | Solidtime down-sync | ✅ shipped | | 3.5 | Test coverage uplift | ✅ shipped | | 3d | UX polish + sync resilience | ✅ shipped | -| 4 | Distribution (Homebrew cask + signing + release CD) | 🔜 planned | -| 5 | Documentation site (GitHub Pages) | 🔜 planned | +| 4 | Distribution (Homebrew cask + signing + release CD) | ✅ shipped | +| 5 | Documentation site (GitHub Pages) | ✅ shipped | +| 6a | verbs façade + MCP + HTTP API + URL scheme + skill installer | ✅ shipped | +| 6b | Spotlight + App Intents + Focus filter | ⚠️ partial — Spotlight tap-to-focus-entry works; Siri/Shortcuts.app discovery deferred | +| 6c | Raycast + Alfred + WidgetKit + idle detection | ✅ shipped | --- diff --git a/alfred-stint/README.md b/alfred-stint/README.md new file mode 100644 index 0000000..5a2c141 --- /dev/null +++ b/alfred-stint/README.md @@ -0,0 +1,41 @@ +# Stint for Alfred + +Four keyword shortcuts for [stint](https://github.com/reyemtech/stint): + +| Keyword | What it does | +|---|---| +| `s ` | Start a timer with that description | +| `sstop` | Stop the running timer | +| `scur` | Show the running timer | +| `srec` | List recent entries; ⏎ restarts, ⌥⏎ opens in Stint | + +## Install + +1. Double-click `Stint.alfredworkflow` from the GitHub Releases page. +2. Alfred prompts to import. +3. Make sure the `stint` CLI is in PATH (or set the Workflow Environment + Variable `STINT_BIN`). + +## First-time setup after import + +This directory ships a minimal `info.plist` skeleton — Alfred needs the +four keywords wired to the corresponding scripts. After importing: + +1. Open Alfred Preferences → Workflows → Stint. +2. Add four objects: + - Keyword `s` (argument required) → Run Script (`bash`) → + `./start.sh "{query}"`. + - Keyword `sstop` → Run Script (`bash`) → `./stop.sh`. + - Script Filter, keyword `scur` → `bash` → `./current.sh`. Open URL on + selection. + - Script Filter, keyword `srec` → `bash` → `./recent.sh`. ⏎ runs + `./start.sh "$(./describe.sh {query})"`, ⌥⏎ opens the URL. +3. Export the workflow over this directory to lock in the wiring. + +## Build from source + +This directory IS the workflow source. Bundle: + +```bash +zip -r Stint.alfredworkflow . -x ".*" +``` diff --git a/alfred-stint/current.sh b/alfred-stint/current.sh new file mode 100755 index 0000000..aa8f0c5 --- /dev/null +++ b/alfred-stint/current.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/lib.sh" + +BIN="$(resolve_bin)" || { + cat </dev/null || echo "null")" +if [[ "$JSON" == "null" ]] || [[ -z "$JSON" ]]; then + echo '{"items":[{"title":"No active timer","valid":false}]}' + exit 0 +fi +python3 - < + + + + bundleid + tech.reyem.stint.alfred + name + Stint + description + Start, stop, and inspect Stint time entries from Alfred. + version + 0.1.0 + createdby + Reyem Technologies + readme + See README.md + objects + + connections + + uidata + + + diff --git a/alfred-stint/lib.sh b/alfred-stint/lib.sh new file mode 100755 index 0000000..f9e9718 --- /dev/null +++ b/alfred-stint/lib.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Shared helpers for Stint Alfred workflow scripts. + +resolve_bin() { + if [[ -n "$STINT_BIN" ]] && [[ -x "$STINT_BIN" ]]; then + echo "$STINT_BIN" + return + fi + if command -v stint >/dev/null 2>&1; then + command -v stint + return + fi + for candidate in "$HOME/.cargo/bin/stint" "/Applications/Stint.app/Contents/MacOS/stint"; do + [[ -x "$candidate" ]] && { echo "$candidate"; return; } + done + return 1 +} diff --git a/alfred-stint/recent.sh b/alfred-stint/recent.sh new file mode 100755 index 0000000..2d94460 --- /dev/null +++ b/alfred-stint/recent.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/lib.sh" + +BIN="$(resolve_bin)" || { + echo '{"items":[{"title":"Stint binary not found","valid":false}]}' + exit 0 +} + +JSON="$("$BIN" --json list --limit 20 2>/dev/null || echo "[]")" +python3 - < Result<(), String> { + if env::var_os("STINT_SKIP_SWIFT_BUILD").is_some_and(|v| !v.is_empty()) { + return Err("STINT_SKIP_SWIFT_BUILD is set".into()); + } + if env::var_os("CARGO_CFG_TARGET_OS").is_some_and(|v| v != "macos") { + return Err("non-macOS target".into()); + } + + let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?; + let swift_dir = Path::new(&manifest_dir).join("swift/StintIntents"); + let package_swift = swift_dir.join("Package.swift"); + if !package_swift.exists() { + return Err(format!("missing {}", package_swift.display())); + } + + println!("cargo:rerun-if-changed={}", package_swift.display()); + let sources_dir = swift_dir.join("Sources/StintIntents"); + if let Ok(entries) = fs::read_dir(&sources_dir) { + for entry in entries.flatten() { + print_rerun_if_changed_recursive(&entry.path()); + } + } + println!("cargo:rerun-if-env-changed=STINT_SKIP_SWIFT_BUILD"); + + let derived_data = swift_dir.join("build/derived"); + + let status = Command::new("xcodebuild") + .current_dir(&swift_dir) + .args([ + "-scheme", + "StintIntents", + "-configuration", + "Release", + "-destination", + "platform=macOS", + "-derivedDataPath", + derived_data.to_str().ok_or("derived path not utf8")?, + "build", + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .status() + .map_err(|e| format!("xcodebuild spawn: {e}"))?; + if !status.success() { + return Err(format!("xcodebuild exit {status}")); + } + + let built_framework = + derived_data.join("Build/Products/Release/PackageFrameworks/StintIntents.framework"); + let metadata_bundle = + derived_data.join("Build/Products/Release/StintIntents.appintents/Metadata.appintents"); + if !built_framework.exists() { + return Err(format!("missing {}", built_framework.display())); + } + + let dest = Path::new(&manifest_dir).join("Frameworks/StintIntents.framework"); + let _ = fs::remove_dir_all(&dest); + copy_dir(&built_framework, &dest).map_err(|e| format!("copy framework: {e}"))?; + + // Metadata.appintents stencil is best-effort: appintentsmetadataprocessor + // only emits it on Xcode versions where the framework's deployment-target + // story aligns with the App Intents discovery contract. Xcode 26 emits; + // some older toolchains (e.g. CI's macos-14 image with Xcode 15) skip the + // stencil when WidgetKit imports raise the effective deployment target. + // Either way the framework intents aren't discovered by Apple's indexer + // (the 6b deferral) — Phase 6d resolves this via a real .appex. Just + // warn and continue without the stencil. + if metadata_bundle.exists() { + let dest_meta = dest.join("Versions/A/Resources/Metadata.appintents"); + let _ = fs::remove_dir_all(&dest_meta); + copy_dir(&metadata_bundle, &dest_meta).map_err(|e| format!("copy metadata: {e}"))?; + + let info_plist = dest.join("Versions/A/Resources/Info.plist"); + patch_info_plist(&info_plist).map_err(|e| format!("patch Info.plist: {e}"))?; + } else { + println!( + "cargo:warning=StintIntents Metadata.appintents not produced by xcodebuild; \ + shipping framework without the stencil (Apple indexer wouldn't have used it \ + anyway — see Phase 6d for the real fix)" + ); + } + + codesign_adhoc(&dest).map_err(|e| format!("codesign framework: {e}"))?; + + // Link the framework into stint-app at build time. Without + // -needed_framework the linker would dead-strip the LC_LOAD_DYLIB + // record because no Rust code references its symbols at link time + // (everything goes through dlsym). @executable_path/../Frameworks + // matches Tauri's bundle.macOS.frameworks copy destination. + let frameworks_dir = Path::new(&manifest_dir).join("Frameworks"); + println!("cargo:rustc-link-arg=-Wl,-F,{}", frameworks_dir.display()); + println!("cargo:rustc-link-arg=-Wl,-needed_framework,StintIntents"); + // Production rpath: Tauri copies the framework into Stint.app/Contents/Frameworks/ + // and the binary lives at Stint.app/Contents/MacOS/stint-app. + println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/../Frameworks"); + // Dev/test rpath: cargo test binaries live at target/$profile/deps/ and + // the framework gets copied to crates/stint-app/Frameworks/ by this + // build script. The absolute path is harmless in production binaries + // (dyld stops searching at the first rpath that resolves). + println!( + "cargo:rustc-link-arg=-Wl,-rpath,{}", + frameworks_dir.display() + ); + // The framework was built with -undefined dynamic_lookup; its calls to + // stint_verb_*, stint_settings_*, etc. need to resolve against this + // binary's flat namespace at load time. -export_dynamic exposes the + // Rust #[no_mangle] symbols so dyld finds them; without this, dyld + // aborts launch with "symbol not found in flat namespace". + println!("cargo:rustc-link-arg=-Wl,-export_dynamic"); + + println!( + "cargo:warning=StintIntents framework rebuilt at {}", + dest.display() + ); + + Ok(()) +} + +/// Build the StintWidget Swift package and repackage the framework as a +/// proper `.appex` bundle at `crates/stint-app/PlugIns/StintWidget.appex/`. +/// Tauri's bundle step copies that directory into +/// `Stint.app/Contents/PlugIns/`, which is where WidgetKit looks for +/// widget extensions. +fn build_stint_widget() -> Result<(), String> { + if env::var_os("STINT_SKIP_SWIFT_BUILD").is_some_and(|v| !v.is_empty()) { + return Err("STINT_SKIP_SWIFT_BUILD is set".into()); + } + if env::var_os("CARGO_CFG_TARGET_OS").is_some_and(|v| v != "macos") { + return Err("non-macOS target".into()); + } + + let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?; + let swift_dir = Path::new(&manifest_dir).join("swift/StintWidget"); + let package_swift = swift_dir.join("Package.swift"); + if !package_swift.exists() { + return Err(format!("missing {}", package_swift.display())); + } + + println!("cargo:rerun-if-changed={}", package_swift.display()); + let sources_dir = swift_dir.join("Sources/StintWidget"); + if let Ok(entries) = fs::read_dir(&sources_dir) { + for entry in entries.flatten() { + print_rerun_if_changed_recursive(&entry.path()); + } + } + + let derived_data = swift_dir.join("build/derived"); + let status = Command::new("xcodebuild") + .current_dir(&swift_dir) + .args([ + "-scheme", + "StintWidget", + "-configuration", + "Release", + "-destination", + "platform=macOS", + "-derivedDataPath", + derived_data.to_str().ok_or("derived path not utf8")?, + "build", + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .status() + .map_err(|e| format!("xcodebuild spawn: {e}"))?; + if !status.success() { + return Err(format!("xcodebuild exit {status}")); + } + + // The widget package builds as an executableTarget so the produced + // Mach-O is the kind of binary Apple's .appex loader expects (a real + // executable with @main bootstrap, not a dylib). + let executable = derived_data.join("Build/Products/Release/StintWidget"); + if !executable.exists() { + return Err(format!("missing {}", executable.display())); + } + + let dest = Path::new(&manifest_dir).join("PlugIns/StintWidget.appex"); + let _ = fs::remove_dir_all(&dest); + fs::create_dir_all(dest.join("Contents/MacOS")).map_err(|e| format!("create dirs: {e}"))?; + fs::copy(&executable, dest.join("Contents/MacOS/StintWidget")) + .map_err(|e| format!("copy executable: {e}"))?; + + let info_plist = r#" + + + + CFBundleIdentifier + tech.reyem.stint.widget + CFBundleExecutable + StintWidget + CFBundleName + StintWidget + CFBundleDisplayName + Stint Widget + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + XPC! + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDevelopmentRegion + en + CFBundleSupportedPlatforms + + MacOSX + + DTPlatformName + macosx + LSMinimumSystemVersion + 14.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + +"#; + fs::write(dest.join("Contents/Info.plist"), info_plist) + .map_err(|e| format!("write Info.plist: {e}"))?; + + let stencil = + derived_data.join("Build/Products/Release/StintWidget.appintents/Metadata.appintents"); + if stencil.exists() { + let dst = dest.join("Contents/Resources/Metadata.appintents"); + let _ = fs::remove_dir_all(&dst); + fs::create_dir_all(dst.parent().unwrap()).map_err(|e| format!("create resources: {e}"))?; + copy_dir(&stencil, &dst).map_err(|e| format!("copy stencil: {e}"))?; + } + + codesign_adhoc(&dest).map_err(|e| format!("codesign appex: {e}"))?; + + println!( + "cargo:warning=StintWidget.appex rebuilt at {}", + dest.display() + ); + Ok(()) +} + +fn print_rerun_if_changed_recursive(path: &Path) { + if let Ok(meta) = fs::metadata(path) { + if meta.is_dir() { + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + print_rerun_if_changed_recursive(&entry.path()); + } + } + } else { + println!("cargo:rerun-if-changed={}", path.display()); + } + } +} + +fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let typ = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if typ.is_symlink() { + let target = fs::read_link(&src_path)?; + let _ = std::os::unix::fs::symlink(target, &dst_path); + } else if typ.is_dir() { + copy_dir(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +fn codesign_adhoc(framework: &Path) -> Result<(), String> { + let status = Command::new("codesign") + .args([ + "--force", + "--sign", + "-", + framework.to_str().ok_or("path not utf8")?, + ]) + .status() + .map_err(|e| format!("codesign spawn: {e}"))?; + if !status.success() { + return Err(format!("codesign exit {status}")); + } + Ok(()) +} + +fn patch_info_plist(path: &Path) -> Result<(), String> { + if !path.exists() { + return Err(format!("missing {}", path.display())); + } + let status = Command::new("plutil") + .args([ + "-insert", + "NSAppIntentsPackage", + "-bool", + "YES", + path.to_str().ok_or("path not utf8")?, + ]) + .status() + .map_err(|e| format!("plutil spawn: {e}"))?; + if !status.success() { + let replace = Command::new("plutil") + .args([ + "-replace", + "NSAppIntentsPackage", + "-bool", + "YES", + path.to_str().ok_or("path not utf8")?, + ]) + .status() + .map_err(|e| format!("plutil replace spawn: {e}"))?; + if !replace.success() { + return Err(format!("plutil failed: {replace}")); + } + } + Ok(()) +} diff --git a/crates/stint-app/src/commands/idle.rs b/crates/stint-app/src/commands/idle.rs new file mode 100644 index 0000000..73a0a02 --- /dev/null +++ b/crates/stint-app/src/commands/idle.rs @@ -0,0 +1,52 @@ +//! Tauri commands backing the IdleBanner.tsx buttons. The user gets: +//! Keep — banner dismisses; entry untouched. +//! Discard — end the entry at idle_started; subtract the idle period. +//! Split — same storage behavior as Discard; UI distinguishes by +//! pre-filling the start form for one-click resume. + +use stint_core::store::entries::Entries; +use stint_core::store::running::RunningTimer; +use stint_core::store::Store; +use stint_core::{Error, Result}; +use tauri::State; +use tokio::sync::RwLock; + +/// Pure backend helper — exposed so tests can exercise without going through +/// Tauri's runtime. +pub async fn discard_impl(store: &Store, idle_started: &str) -> Result<()> { + let running = RunningTimer::new(store.clone()) + .get() + .await? + .ok_or_else(|| Error::Invariant("no running timer".into()))?; + let entries = Entries::new(store.clone()); + entries.set_end(&running.local_uuid, idle_started).await?; + RunningTimer::new(store.clone()).clear().await?; + Ok(()) +} + +#[tauri::command] +pub async fn idle_keep() -> std::result::Result<(), String> { + Ok(()) +} + +#[tauri::command] +pub async fn idle_discard( + idle_started: String, + state: State<'_, RwLock>, +) -> std::result::Result<(), String> { + let store = state.read().await.store.clone(); + discard_impl(&store, &idle_started) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn idle_split( + idle_started: String, + state: State<'_, RwLock>, +) -> std::result::Result<(), String> { + let store = state.read().await.store.clone(); + discard_impl(&store, &idle_started) + .await + .map_err(|e| e.to_string()) +} diff --git a/crates/stint-app/src/commands/mod.rs b/crates/stint-app/src/commands/mod.rs index 05ee6b1..ad916dd 100644 --- a/crates/stint-app/src/commands/mod.rs +++ b/crates/stint-app/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod calendar; pub mod config; pub mod entries; +pub mod idle; pub mod integrations; pub mod projects; pub mod pull; diff --git a/crates/stint-app/src/commands/projects.rs b/crates/stint-app/src/commands/projects.rs index 8f86322..8eb8d3f 100644 --- a/crates/stint-app/src/commands/projects.rs +++ b/crates/stint-app/src/commands/projects.rs @@ -6,6 +6,7 @@ use stint_core::solidtime::auth::build_token_provider; use stint_core::solidtime::SolidtimeClient; use stint_core::store::reference::{ProjectRow, Reference}; use stint_core::sync::refresh::refresh_reference_data; +use stint_core::verbs::{self, TaskView}; use tauri::State; use tokio::sync::RwLock; @@ -70,6 +71,18 @@ pub async fn list_projects( Ok(r.list_projects().await?) } +/// Delegates to `stint_core::verbs::list_tasks`. Passing `project_id` scopes +/// the result to one project; omitting it returns every locally-cached task +/// (used sparingly — the picker always passes a project_id). +#[tauri::command] +pub async fn list_tasks( + state: State<'_, RwLock>, + project_id: Option, +) -> Result, AppError> { + let store = store(&state).await; + Ok(verbs::list_tasks(&store, project_id).await?) +} + #[tauri::command] pub async fn refresh_projects(state: State<'_, RwLock>) -> Result { let store = store(&state).await; diff --git a/crates/stint-app/src/commands/timer.rs b/crates/stint-app/src/commands/timer.rs index 5cb8621..f06eacf 100644 --- a/crates/stint-app/src/commands/timer.rs +++ b/crates/stint-app/src/commands/timer.rs @@ -175,6 +175,27 @@ pub async fn set_entry_project( Ok(()) } +#[tauri::command] +pub async fn set_entry_task( + app: AppHandle, + state: State<'_, RwLock>, + local_uuid: String, + task_id: Option, +) -> Result<(), AppError> { + let store = store(&state).await; + // Same "null = clear, value = set" semantics as set_entry_project: the + // Tauri arg has no distinct "absent" state, so we always lift the + // Option into the 3-way patch slot. + let patch = EntryPatch { + task_id: Some(task_id), + ..Default::default() + }; + verbs::update_entry(&store, &local_uuid, patch).await?; + announce_change(&app); + sync_worker::nudge(app.clone(), store); + Ok(()) +} + #[tauri::command] pub async fn set_entry_billable( app: AppHandle, diff --git a/crates/stint-app/src/http/mod.rs b/crates/stint-app/src/http/mod.rs index b70bee1..51116c1 100644 --- a/crates/stint-app/src/http/mod.rs +++ b/crates/stint-app/src/http/mod.rs @@ -7,6 +7,7 @@ pub mod handlers; use axum::routing::{delete, get, patch, post}; use axum::Router; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; use stint_core::config::{Settings, DEFAULT_API_HOST, KEY_API_ENABLED, KEY_API_HOST, KEY_API_PORT}; use stint_core::store::Store; @@ -14,6 +15,43 @@ use stint_core::Result; use tokio::net::TcpListener; use tokio::sync::RwLock; +fn port_file_path() -> Result { + Ok(stint_core::paths::data_dir()?.join("api.port")) +} + +fn write_port_file(port: u16) -> Result<()> { + let path = port_file_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, format!("{port}\n"))?; + Ok(()) +} + +fn remove_port_file() -> Result<()> { + let path = port_file_path()?; + let _ = std::fs::remove_file(&path); + Ok(()) +} + +/// Write the port file and return the port. Exposed for integration tests +/// via the `test-utils` feature. +#[doc(hidden)] +#[cfg(feature = "test-utils")] +#[allow(dead_code)] // called from integration-test binaries, not from the lib/bin itself +pub fn write_port_file_for_test(port: u16) -> Result { + write_port_file(port)?; + Ok(port) +} + +/// Remove the port file. Exposed for integration tests via the `test-utils` feature. +#[doc(hidden)] +#[cfg(feature = "test-utils")] +#[allow(dead_code)] // called from integration-test binaries, not from the lib/bin itself +pub fn remove_port_file_for_test() -> Result<()> { + remove_port_file() +} + /// Build the axum router. Exposed so integration tests can drive it via /// `tower::ServiceExt::oneshot` without binding a real socket. pub fn build_router(store: Arc) -> Router { @@ -63,12 +101,14 @@ pub async fn maybe_spawn( let bound = listener.local_addr().unwrap().port(); settings.set(KEY_API_PORT, &bound.to_string()).await?; *port_slot.write().await = Some(bound); + let _ = write_port_file(bound); // best-effort; widget falls back to placeholder if missing let app = build_router(store); tokio::spawn(async move { if let Err(e) = axum::serve(listener, app).await { tracing::error!("http api server exited: {e}"); } + let _ = remove_port_file(); // clean up on graceful shutdown; stale file on crash is harmless }); Ok(Some(bound)) diff --git a/crates/stint-app/src/idle_detector.rs b/crates/stint-app/src/idle_detector.rs new file mode 100644 index 0000000..fea4b29 --- /dev/null +++ b/crates/stint-app/src/idle_detector.rs @@ -0,0 +1,163 @@ +//! Idle detector — polls CGEvent every 60s, emits an event on activity- +//! resume after the configured threshold has elapsed. +//! +//! The pure state machine in `advance()` is testable without macOS APIs; +//! the live polling loop (added in Task A4) calls `idle_seconds()` which +//! links against CoreGraphics. + +use serde::Serialize; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct IdleState { + /// Unix timestamp (seconds) when idleness began; Some once threshold + /// has been reached and we're awaiting activity-resume. + pub pending_idle: Option, +} + +#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +pub struct IdleEvent { + /// Epoch seconds when the idle period started. + pub idle_started: u64, + pub idle_secs: u64, +} + +/// Advance the state machine one tick. Pure function; no I/O. +/// +/// * `idle_secs` — CGEvent's "seconds since any input" +/// * `now` — current unix epoch seconds +/// * `threshold` — idle.threshold_secs setting +/// * `timer_running` — whether there's a running entry to attribute the gap to +pub fn advance( + state: &mut IdleState, + idle_secs: f64, + now: u64, + threshold: u32, + timer_running: bool, +) -> Option { + // No timer → nothing to attribute idle to. Drop any pending state. + if !timer_running { + state.pending_idle = None; + return None; + } + + let idle_secs = idle_secs.max(0.0) as u64; + let threshold = threshold as u64; + + // Activity resumed after threshold was previously reached → emit. + if let Some(idle_started) = state.pending_idle { + if idle_secs < 60 { + let evt = IdleEvent { + idle_started, + idle_secs: now.saturating_sub(idle_started), + }; + state.pending_idle = None; + return Some(evt); + } + // Still idle; no change. + return None; + } + + // Not yet armed. Arm if we crossed the threshold. + if idle_secs >= threshold { + state.pending_idle = Some(now.saturating_sub(idle_secs)); + } + None +} + +// ---- platform-dependent polling ---- + +#[cfg(target_os = "macos")] +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGEventSourceSecondsSinceLastEventType(source_state_id: i32, event_type: u32) -> f64; +} + +/// Seconds since the last user input event (mouse / keyboard / etc). +/// macOS-only; on other platforms returns 0.0 (effectively disables the +/// detector). +#[cfg(target_os = "macos")] +pub fn idle_seconds() -> f64 { + // source_state_id = 0 (combined session state), + // event_type = u32::MAX (kCGAnyInputEventType) + unsafe { CGEventSourceSecondsSinceLastEventType(0, u32::MAX) } +} + +#[cfg(not(target_os = "macos"))] +pub fn idle_seconds() -> f64 { + 0.0 +} + +// ---- live polling loop ---- + +use std::sync::Arc; +use std::time::Duration; +use stint_core::store::Store; +use tauri::{AppHandle, Emitter, Runtime}; +use tokio::time::interval; +use tracing::{debug, info}; + +const TICK: Duration = Duration::from_secs(60); + +/// Spawn the background idle-detector task. Lives for the GUI process lifetime. +pub fn spawn(app: AppHandle, store: Arc) { + tokio::spawn(async move { + info!("idle detector started (tick = {:?})", TICK); + let mut state = IdleState::default(); + let mut tick = interval(TICK); + loop { + tick.tick().await; + if let Err(e) = tick_once(&app, &store, &mut state).await { + debug!("idle detector tick error: {e}"); + } + } + }); +} + +async fn tick_once( + app: &AppHandle, + store: &Store, + state: &mut IdleState, +) -> stint_core::Result<()> { + let settings = stint_core::config::Settings::new(store.clone()); + let enabled: bool = settings + .get("idle.enabled") + .await? + .as_deref() + .map(|s| s != "false") + .unwrap_or(true); + if !enabled { + state.pending_idle = None; + return Ok(()); + } + let threshold: u32 = settings + .get("idle.threshold_secs") + .await? + .and_then(|s| s.parse().ok()) + .unwrap_or(600) + .clamp(60, 86_400); + + // Timer running? + let running = stint_core::store::running::RunningTimer::new(store.clone()) + .get() + .await? + .is_some(); + + let idle = idle_seconds(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + if let Some(evt) = advance(state, idle, now, threshold, running) { + let iso = chrono::DateTime::::from_timestamp(evt.idle_started as i64, 0) + .map(|d| d.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_default(); + let payload = serde_json::json!({ + "idle_started": iso, + "idle_secs": evt.idle_secs, + }); + info!(?evt, "idle detected; emitting idle:detected"); + let _ = app.emit("idle:detected", payload); + } + Ok(()) +} diff --git a/crates/stint-app/src/lib.rs b/crates/stint-app/src/lib.rs index a61fd53..46a1291 100644 --- a/crates/stint-app/src/lib.rs +++ b/crates/stint-app/src/lib.rs @@ -7,6 +7,7 @@ pub mod app_state; pub mod calendar_worker; pub mod commands; pub mod http; +pub mod idle_detector; pub mod menu; pub mod pull_worker; pub mod sync_worker; diff --git a/crates/stint-app/src/main.rs b/crates/stint-app/src/main.rs index a97e084..09b21d7 100644 --- a/crates/stint-app/src/main.rs +++ b/crates/stint-app/src/main.rs @@ -2,6 +2,7 @@ mod app_state; mod calendar_worker; mod commands; mod http; +mod idle_detector; mod logging; mod menu; mod pull_worker; @@ -47,11 +48,13 @@ async fn main() -> Result<()> { commands::timer::delete_entry, commands::timer::update_description, commands::timer::set_entry_project, + commands::timer::set_entry_task, commands::timer::set_entry_billable, commands::timer::update_entry_times, commands::entries::list_today, commands::entries::list_between, commands::projects::list_projects, + commands::projects::list_tasks, commands::projects::refresh_projects, commands::projects::list_organizations, commands::pull::pull_now, @@ -83,6 +86,9 @@ async fn main() -> Result<()> { commands::integrations::get_api_integration_state, commands::integrations::set_api_enabled, commands::ui::show_main_window, + commands::idle::idle_keep, + commands::idle::idle_discard, + commands::idle::idle_split, updater::check_for_updates, updater::install_update, updater::restart_app, @@ -90,6 +96,23 @@ async fn main() -> Result<()> { .setup(move |app| { tray::build(app.handle())?; + // Initialize the StintIntents Swift framework if it's loaded into + // the app bundle. The framework exports stint_intents_init as an + // @_cdecl symbol; we look it up via dlsym so this path no-ops on + // builds where the framework is absent (raw dev binaries from + // scripts/dev-app.sh, missing build artifacts, etc). + init_stint_intents(); + + // Widget-presence-aware HTTP auto-enable. If ≥1 stint widget is + // already configured, flip api.enabled = true so the widget can + // fetch its data without the user having to find Settings. + { + let store_for_widget_check = store_for_worker.clone(); + tokio::spawn(async move { + auto_enable_api_if_widgets_present(&store_for_widget_check).await; + }); + } + // Register stint:// URL scheme handler. Each incoming URL is parsed // by stint_core::url_scheme and dispatched to the verbs façade. { @@ -131,6 +154,10 @@ async fn main() -> Result<()> { // Periodic Solidtime → stint pull (5-min tick). pull_worker::spawn(app.handle().clone(), store_for_worker.clone()); + // Idle detector — emits idle:detected when activity resumes after + // the configured threshold while a timer is running. + idle_detector::spawn(app.handle().clone(), store_for_worker.clone()); + // One-shot pull on startup: surfaces a remote-side running timer // or recent edits within ~1s of launch, without waiting for the // 5-min background poll worker. @@ -256,12 +283,132 @@ async fn handle_stint_url( Action::Stop => { stint_core::verbs::stop(&store).await?; } - Action::OpenEntry { local_uuid: _ } | Action::Current => { - if let Some(win) = app.get_webview_window("main") { - let _ = win.show(); - let _ = win.set_focus(); - } + Action::OpenEntry { local_uuid } => { + // Look up the entry's start_at so we can navigate to the day + // it belongs to (Today only shows today; a stint:// link from + // Spotlight may point at an older entry). + let route = match stint_core::verbs::list_entries( + &store, + stint_core::verbs::EntryFilter { + limit: Some(1000), + ..Default::default() + }, + ) + .await + { + Ok(entries) => entries + .into_iter() + .find(|e| e.local_uuid == local_uuid) + .map(|e| { + // Pass entry+date so Today (or future routes) can + // scroll to / highlight the row. + let date = e.start_at.split('T').next().unwrap_or("").to_string(); + format!("/today?entry={local_uuid}&date={date}") + }) + .unwrap_or_else(|| format!("/today?entry={local_uuid}")), + Err(_) => format!("/today?entry={local_uuid}"), + }; + focus_main_window_at_route(app, &route); + } + Action::Current => { + focus_main_window_at_route(app, "/today"); + } + Action::OpenProject { project_id } => { + focus_main_window_at_route(app, &format!("/today?project={project_id}")); + } + Action::OpenTask { task_id } => { + // Resolve task → parent project so the Today view can filter by both. + let route = match stint_core::verbs::list_tasks(&store, None).await { + Ok(tasks) => tasks + .into_iter() + .find(|t| t.solidtime_id == task_id) + .map(|t| format!("/today?project={}&task={}", t.project_id, task_id)) + .unwrap_or_else(|| "/today".into()), + Err(_) => "/today".into(), + }; + focus_main_window_at_route(app, &route); } } Ok(()) } + +/// Bring the main window forward and emit a navigate event so the SolidJS +/// router can land on the requested route. Payload is a bare string to +/// match the existing `navigate` listener in `ui/src/App.tsx` (set by the +/// tray menu and Settings shortcuts). +fn focus_main_window_at_route(app: &tauri::AppHandle, route: &str) { + use tauri::Emitter; + if let Some(win) = app.get_webview_window("main") { + let _ = win.show(); + let _ = win.set_focus(); + } + let _ = app.emit("navigate", route); +} + +/// Best-effort init of the StintIntents Swift framework via dlsym lookup +/// of `stint_intents_init`. No-op when the symbol isn't present (the +/// framework isn't bundled into the running binary). +/// Best-effort init of the StintIntents Swift framework via dlsym lookup +/// of `stint_intents_init`. The framework loads dynamically at app launch +/// (build.rs emits -needed_framework so LC_LOAD_DYLIB references it). At +/// the first call, the framework's @_cdecl symbol is resolvable via the +/// flat dyld namespace. +fn init_stint_intents() { + use std::ffi::CString; + type InitFn = unsafe extern "C" fn() -> i32; + let name = CString::new("stint_intents_init").unwrap(); + let sym = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr()) }; + if sym.is_null() { + tracing::debug!( + "stint_intents_init not present; Spotlight/App Intents integration disabled" + ); + return; + } + let f: InitFn = unsafe { std::mem::transmute(sym) }; + let rc = unsafe { f() }; + if rc != 0 { + tracing::warn!(rc, "stint_intents_init returned non-zero"); + } else { + tracing::info!("StintIntents framework initialized"); + } +} + +/// If ≥1 Stint widget is configured AND api.enabled is currently false, +/// flip it to true. The widget needs the loopback HTTP API to fetch its +/// data; auto-enabling removes the "why is my widget showing 'Stint not +/// running'?" onboarding friction. +/// +/// `stint_widget_count` lives in the StintIntents framework — the widget +/// extension itself runs in a separate process and isn't dlsym-reachable. +/// Returns -1 (treated as "no info") when the symbol isn't loaded or +/// WidgetCenter can't enumerate within 2s. +async fn auto_enable_api_if_widgets_present(store: &stint_core::store::Store) { + use std::ffi::CString; + type CountFn = unsafe extern "C" fn() -> i32; + let name = CString::new("stint_widget_count").unwrap(); + let sym = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr()) }; + if sym.is_null() { + return; + } + let f: CountFn = unsafe { std::mem::transmute(sym) }; + let count = unsafe { f() }; + if count <= 0 { + return; + } + let settings = stint_core::config::Settings::new(store.clone()); + let already_on = matches!( + settings.get("api.enabled").await.ok().flatten().as_deref(), + Some("true") + ); + if already_on { + return; + } + if let Err(e) = settings.set("api.enabled", "true").await { + tracing::warn!(error = %e, "auto-enable api.enabled failed"); + return; + } + tracing::info!( + widgets = count, + "auto-enabled api.enabled — ≥1 stint widget is configured" + ); +} diff --git a/crates/stint-app/src/pull_worker.rs b/crates/stint-app/src/pull_worker.rs index f662918..13c8188 100644 --- a/crates/stint-app/src/pull_worker.rs +++ b/crates/stint-app/src/pull_worker.rs @@ -6,9 +6,11 @@ use std::sync::Arc; use std::time::Duration; use stint_core::{ config::{secrets::Secrets, Settings}, + ffi::{notify_indexer, IndexerKind}, solidtime::{auth::build_token_provider, SolidtimeClient}, store::Store, sync::pull::{pull, Trigger}, + verbs, }; use tauri::{AppHandle, Emitter}; use tokio::time::sleep; @@ -51,6 +53,20 @@ async fn tick(app: &AppHandle, store: &Store, trigger: Trigger) -> stint_core::R use crate::commands::pull::ConflictDto; let _ = app.emit(EVENT_PULL_CONFLICT, ConflictDto::from(conflict)); } + + // Refresh the Spotlight project / task slices after a successful pull. + // No-op when the StintIntents framework isn't loaded. + if let Ok(projects) = verbs::list_projects(store).await { + if let Ok(payload) = serde_json::to_string(&projects) { + notify_indexer(IndexerKind::ProjectsReplaced, &payload); + } + } + if let Ok(tasks) = verbs::list_tasks(store, None).await { + if let Ok(payload) = serde_json::to_string(&tasks) { + notify_indexer(IndexerKind::TasksReplaced, &payload); + } + } + Ok(()) } diff --git a/crates/stint-app/swift/StintIntents/.gitignore b/crates/stint-app/swift/StintIntents/.gitignore new file mode 100644 index 0000000..1c69540 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/.gitignore @@ -0,0 +1,6 @@ +.build/ +build/ +.swiftpm/ +DerivedData/ +*.xcodeproj/xcuserdata/ +Frameworks/ diff --git a/crates/stint-app/swift/StintIntents/Package.swift b/crates/stint-app/swift/StintIntents/Package.swift new file mode 100644 index 0000000..c98004a --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Package.swift @@ -0,0 +1,42 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "StintIntents", + platforms: [.macOS(.v13)], + products: [ + // Dynamic framework — static linking into the Tauri-built stint-app + // binary clashes with WebKit's Swift runtime expectations (executor + // lookups SIGSEGV at startup). The framework approach keeps Swift + // isolated and works for Spotlight indexing. Siri/Shortcuts.app + // discovery remains a separate undocumented gap; see spec §1.5. + .library(name: "StintIntents", type: .dynamic, targets: ["StintIntents"]), + ], + targets: [ + .target( + name: "StintIntents", + path: "Sources/StintIntents", + exclude: ["Shortcuts/PhraseStrings.xcstrings"], + resources: [ + .process("Shortcuts/PhraseStrings.xcstrings"), + ], + linkerSettings: [ + // The C symbols (stint_verb_*, stint_settings_*, ...) live + // in stint-core which is statically linked into the Tauri- + // built Stint binary, not into this framework. Defer symbol + // resolution until load time when the framework is loaded + // into the host process and the host's symbols become + // visible via flat-namespace dlsym. + .unsafeFlags([ + "-Xlinker", "-undefined", + "-Xlinker", "dynamic_lookup", + ]), + ] + ), + .testTarget( + name: "StintIntentsTests", + dependencies: ["StintIntents"], + path: "Tests/StintIntentsTests" + ), + ] +) diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Bridge.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Bridge.swift new file mode 100644 index 0000000..90ce4a8 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Bridge.swift @@ -0,0 +1,423 @@ +import Foundation + +// MARK: - C ABI declarations (symbols resolved at app-load time) +// +// We forward-declare the stint-core C functions via @_silgen_name rather +// than importing a clang module. Reason: a single-target Swift Package +// can't import a sibling clang module; introducing a second target would +// complicate framework bundling. The symbols are provided by libstint_core +// (statically linked into the Tauri-built Stint binary). The framework +// itself links with `-undefined dynamic_lookup`. +// +// Signatures MUST stay in sync with: +// crates/stint-core/include/stint_core.h +// crates/stint-core/src/ffi.rs + +@_silgen_name("stint_free_string") +private func stint_free_string(_ ptr: UnsafeMutablePointer?) + +@_silgen_name("stint_verb_start") +private func stint_verb_start(_ params: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 + +@_silgen_name("stint_verb_stop") +private func stint_verb_stop(_ out: UnsafeMutablePointer?>?) -> Int32 + +@_silgen_name("stint_verb_current") +private func stint_verb_current(_ out: UnsafeMutablePointer?>?) -> Int32 + +@_silgen_name("stint_verb_list_entries") +private func stint_verb_list_entries(_ filter: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 + +@_silgen_name("stint_verb_list_projects") +private func stint_verb_list_projects(_ out: UnsafeMutablePointer?>?) -> Int32 + +@_silgen_name("stint_verb_list_tasks") +private func stint_verb_list_tasks(_ params: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 + +@_silgen_name("stint_verb_update_entry") +private func stint_verb_update_entry(_ params: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 + +@_silgen_name("stint_verb_delete_entry") +private func stint_verb_delete_entry(_ params: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 + +@_silgen_name("stint_settings_set") +private func stint_settings_set(_ key: UnsafePointer?, _ value: UnsafePointer?) -> Int32 + +@_silgen_name("stint_settings_get") +private func stint_settings_get(_ key: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 + +@_silgen_name("stint_settings_clear") +private func stint_settings_clear(_ key: UnsafePointer?) -> Int32 + +@_silgen_name("stint_log_warn") +private func stint_log_warn(_ msg: UnsafePointer?) + +@_silgen_name("stint_current_focus_id") +private func stint_current_focus_id(_ out: UnsafeMutablePointer?>?) -> Int32 + +// MARK: - Envelope decoding + +struct Envelope: Decodable { + let ok: T? + let err: EnvelopeErr? +} + +struct EnvelopeErr: Decodable { + let code: Int + let message: String +} + +// MARK: - DTOs (Rust shapes in verbs/types.rs, encoded snake_case) + +public struct StartParams: Encodable { + public var description: String + public var projectId: String? + public var taskId: String? + public var billable: Bool + public var startAt: String? + public var source: String + + public init( + description: String, + projectId: String? = nil, + taskId: String? = nil, + billable: Bool = false, + startAt: String? = nil, + source: String = "intent" + ) { + self.description = description + self.projectId = projectId + self.taskId = taskId + self.billable = billable + self.startAt = startAt + self.source = source + } + + enum CodingKeys: String, CodingKey { + case description, source, billable + case projectId = "project_id" + case taskId = "task_id" + case startAt = "start_at" + } +} + +public struct EntryFilter: Encodable { + public var since: String? + public var until: String? + public var projectId: String? + public var limit: UInt32? + + public init( + since: String? = nil, + until: String? = nil, + projectId: String? = nil, + limit: UInt32? = nil + ) { + self.since = since + self.until = until + self.projectId = projectId + self.limit = limit + } + + enum CodingKeys: String, CodingKey { + case since, until, limit + case projectId = "project_id" + } +} + +/// 3-way nullable for EntryPatch fields. Encoded as absent / null / value. +public enum NullablePatch: Encodable { + case unchanged + case clear + case set(T) + + public func encode(to encoder: Encoder) throws { + // Container encodes only the value branch; the absent/clear branches + // are handled by EntryPatch.encode below. + var c = encoder.singleValueContainer() + switch self { + case .unchanged: try c.encodeNil() // unreachable in practice + case .clear: try c.encodeNil() + case .set(let v): try c.encode(v) + } + } +} + +public struct EntryPatch: Encodable { + public var description: String? + public var projectId: NullablePatch + public var taskId: NullablePatch + public var billable: Bool? + public var startAt: String? + public var endAt: NullablePatch + + public init( + description: String? = nil, + projectId: NullablePatch = .unchanged, + taskId: NullablePatch = .unchanged, + billable: Bool? = nil, + startAt: String? = nil, + endAt: NullablePatch = .unchanged + ) { + self.description = description + self.projectId = projectId + self.taskId = taskId + self.billable = billable + self.startAt = startAt + self.endAt = endAt + } + + enum CodingKeys: String, CodingKey { + case description, billable + case projectId = "project_id" + case taskId = "task_id" + case startAt = "start_at" + case endAt = "end_at" + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + if let d = description { try c.encode(d, forKey: .description) } + if let b = billable { try c.encode(b, forKey: .billable) } + if let s = startAt { try c.encode(s, forKey: .startAt) } + try encodeNullable(into: &c, key: .projectId, value: projectId) + try encodeNullable(into: &c, key: .taskId, value: taskId) + try encodeNullable(into: &c, key: .endAt, value: endAt) + } + + private func encodeNullable( + into c: inout KeyedEncodingContainer, + key: CodingKeys, + value: NullablePatch + ) throws { + switch value { + case .unchanged: return + case .clear: try c.encodeNil(forKey: key) + case .set(let v): try c.encode(v, forKey: key) + } + } +} + +public struct EntryDTO: Decodable, Equatable, Sendable { + public let localUuid: String + public let solidtimeId: String? + public let description: String + public let projectId: String? + public let taskId: String? + public let billable: Bool + public let startAt: String + public let endAt: String? + public let source: String + + enum CodingKeys: String, CodingKey { + case description, billable, source + case localUuid = "local_uuid" + case solidtimeId = "solidtime_id" + case projectId = "project_id" + case taskId = "task_id" + case startAt = "start_at" + case endAt = "end_at" + } +} + +public struct ProjectDTO: Decodable, Equatable, Sendable { + public let solidtimeId: String + public let name: String + public let color: String? + public let clientId: String? + public let archived: Bool + + enum CodingKeys: String, CodingKey { + case name, color, archived + case solidtimeId = "solidtime_id" + case clientId = "client_id" + } +} + +public struct TaskDTO: Decodable, Equatable, Sendable { + public let solidtimeId: String + public let projectId: String + public let name: String + public let done: Bool + + enum CodingKeys: String, CodingKey { + case name, done + case solidtimeId = "solidtime_id" + case projectId = "project_id" + } +} + +// MARK: - Bridge protocol (testable seam) + +public protocol Bridge: Sendable { + func start(_ params: StartParams) throws -> EntryDTO + func stop() throws -> EntryDTO + func current() throws -> EntryDTO? + func listEntries(_ filter: EntryFilter) throws -> [EntryDTO] + func listProjects() throws -> [ProjectDTO] + func listTasks(projectId: String?) throws -> [TaskDTO] + func updateEntry(localUuid: String, patch: EntryPatch) throws -> EntryDTO + func deleteEntry(localUuid: String) throws + + func settingsSet(_ key: String, _ value: String) throws + func settingsGet(_ key: String) throws -> String? + func settingsClear(_ key: String) throws + + func logWarn(_ msg: String) +} + +// MARK: - Production FFIBridge + +/// Calls the C ABI in stint-core. Symbols are resolved at app-load time +/// (the framework is built with `-undefined dynamic_lookup`; the host +/// Stint binary provides the implementations via libstint_core). +public final class FFIBridge: Bridge, @unchecked Sendable { + public static let shared = FFIBridge() + + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init() {} + + // ---- write helpers ---- + + private func encodeParams(_ params: P) throws -> Data { + try encoder.encode(params) + } + + private func callWriting( + _ verb: (UnsafePointer?, UnsafeMutablePointer?>?) -> Int32, + _ params: P + ) throws -> T { + let data = try encodeParams(params) + let paramsString = String(decoding: data, as: UTF8.self) + var out: UnsafeMutablePointer? + paramsString.withCString { ptr in + _ = verb(ptr, &out) + } + return try decodeEnvelope(out) + } + + private func callReading( + _ verb: (UnsafeMutablePointer?>?) -> Int32 + ) throws -> T { + var out: UnsafeMutablePointer? + _ = verb(&out) + return try decodeEnvelope(out) + } + + private func decodeEnvelope(_ ptr: UnsafeMutablePointer?) throws -> T { + guard let ptr = ptr else { + throw BridgeError.internal("null envelope pointer") + } + defer { stint_free_string(ptr) } + let data = Data(bytes: ptr, count: strlen(ptr)) + let env = try decoder.decode(Envelope.self, from: data) + if let e = env.err { + throw BridgeError.from(code: Int32(e.code), message: e.message) + } + guard let ok = env.ok else { + throw BridgeError.internal("envelope missing both ok and err") + } + return ok + } + + // ---- verbs ---- + + public func start(_ params: StartParams) throws -> EntryDTO { + try callWriting(stint_verb_start, params) + } + + public func stop() throws -> EntryDTO { + try callReading(stint_verb_stop) + } + + public func current() throws -> EntryDTO? { + // current returns Option; ok branch may legitimately be null. + var out: UnsafeMutablePointer? + _ = stint_verb_current(&out) + guard let ptr = out else { return nil } + defer { stint_free_string(ptr) } + let data = Data(bytes: ptr, count: strlen(ptr)) + struct OptionalEnvelope: Decodable { + let ok: EntryDTO? + let err: EnvelopeErr? + } + let env = try decoder.decode(OptionalEnvelope.self, from: data) + if let e = env.err { + throw BridgeError.from(code: Int32(e.code), message: e.message) + } + return env.ok + } + + public func listEntries(_ filter: EntryFilter) throws -> [EntryDTO] { + try callWriting(stint_verb_list_entries, filter) + } + + public func listProjects() throws -> [ProjectDTO] { + try callReading(stint_verb_list_projects) + } + + public func listTasks(projectId: String?) throws -> [TaskDTO] { + struct P: Encodable { + let project_id: String? + } + return try callWriting(stint_verb_list_tasks, P(project_id: projectId)) + } + + public func updateEntry(localUuid: String, patch: EntryPatch) throws -> EntryDTO { + struct P: Encodable { + let local_uuid: String + let patch: EntryPatch + } + return try callWriting(stint_verb_update_entry, P(local_uuid: localUuid, patch: patch)) + } + + public func deleteEntry(localUuid: String) throws { + struct P: Encodable { + let local_uuid: String + } + let _: [String: String] = try callWriting(stint_verb_delete_entry, P(local_uuid: localUuid)) + } + + // ---- settings ---- + + public func settingsSet(_ key: String, _ value: String) throws { + let rc = key.withCString { k in + value.withCString { v in + stint_settings_set(k, v) + } + } + if rc != 0 { + throw BridgeError.internal("settings_set rc=\(rc)") + } + } + + public func settingsGet(_ key: String) throws -> String? { + var out: UnsafeMutablePointer? + let rc = key.withCString { k in stint_settings_get(k, &out) } + if rc != 0 { + throw BridgeError.internal("settings_get rc=\(rc)") + } + guard let ptr = out else { return nil } + defer { stint_free_string(ptr) } + return String(cString: ptr) + } + + public func settingsClear(_ key: String) throws { + let rc = key.withCString { k in stint_settings_clear(k) } + if rc != 0 { + throw BridgeError.internal("settings_clear rc=\(rc)") + } + } + + // ---- log ---- + + public func logWarn(_ msg: String) { + msg.withCString { stint_log_warn($0) } + } +} + +// Suppress the @unchecked Sendable warning: FFIBridge contains JSONEncoder and +// JSONDecoder which are not Sendable, but they are used in a serial manner by +// callers (each call constructs fresh encoded data, no shared mutable state). diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/EntryEntity.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/EntryEntity.swift new file mode 100644 index 0000000..7d46e93 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/EntryEntity.swift @@ -0,0 +1,40 @@ +import AppIntents +import Foundation + +public struct EntryEntity: AppEntity, Identifiable, Sendable { + public static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Time Entry") + public static var defaultQuery = EntryQuery() + + public let id: String // local_uuid + public let entryDescription: String + public let projectId: String? + public let taskId: String? + public let billable: Bool + public let startAt: Date + public let endAt: Date? + + public var duration: Measurement { + let end = endAt ?? Date() + return Measurement(value: end.timeIntervalSince(startAt), unit: .seconds) + } + + public var displayRepresentation: DisplayRepresentation { + let fmt = ISO8601DateFormatter() + let mins = Int(duration.converted(to: .minutes).value) + return DisplayRepresentation( + title: "\(entryDescription)", + subtitle: "\(fmt.string(from: startAt)) · \(mins)m" + ) + } + + public init(from dto: EntryDTO) { + self.id = dto.localUuid + self.entryDescription = dto.description + self.projectId = dto.projectId + self.taskId = dto.taskId + self.billable = dto.billable + let fmt = ISO8601DateFormatter() + self.startAt = fmt.date(from: dto.startAt) ?? Date() + self.endAt = dto.endAt.flatMap { fmt.date(from: $0) } + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/EntryQuery.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/EntryQuery.swift new file mode 100644 index 0000000..aab42f2 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/EntryQuery.swift @@ -0,0 +1,28 @@ +import AppIntents +import Foundation + +public struct EntryQuery: EntityQuery, EntityStringQuery { + public init() {} + + public func entities(for identifiers: [EntryEntity.ID]) async throws -> [EntryEntity] { + // No direct lookup-by-id verb; fetch a wide window and filter client-side. + let all = try FFIBridge.shared + .listEntries(EntryFilter(limit: 500)) + .map(EntryEntity.init(from:)) + return all.filter { identifiers.contains($0.id) } + } + + public func suggestedEntities() async throws -> [EntryEntity] { + try FFIBridge.shared + .listEntries(EntryFilter(limit: 20)) + .map(EntryEntity.init(from:)) + } + + public func entities(matching string: String) async throws -> [EntryEntity] { + let q = string.lowercased() + return try FFIBridge.shared + .listEntries(EntryFilter(limit: 200)) + .map(EntryEntity.init(from:)) + .filter { $0.entryDescription.lowercased().contains(q) } + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/ProjectEntity.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/ProjectEntity.swift new file mode 100644 index 0000000..169b091 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/ProjectEntity.swift @@ -0,0 +1,33 @@ +import AppIntents +import Foundation + +public struct ProjectEntity: AppEntity, Identifiable, Sendable { + public static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project") + public static var defaultQuery = ProjectQuery() + + public let id: String + public let name: String + public let clientName: String? + public let archived: Bool + + public var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(name)", + subtitle: clientName.map { "Project · \($0)" } ?? "Project" + ) + } + + public init(from dto: ProjectDTO) { + self.id = dto.solidtimeId + self.name = dto.name + self.clientName = nil // TODO: pipe through from Solidtime client cache + self.archived = dto.archived + } + + public init(id: String, name: String, clientName: String? = nil, archived: Bool = false) { + self.id = id + self.name = name + self.clientName = clientName + self.archived = archived + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/ProjectQuery.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/ProjectQuery.swift new file mode 100644 index 0000000..c644163 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/ProjectQuery.swift @@ -0,0 +1,26 @@ +import AppIntents +import Foundation + +public struct ProjectQuery: EntityQuery, EntityStringQuery { + public init() {} + + public func entities(for identifiers: [ProjectEntity.ID]) async throws -> [ProjectEntity] { + let all = try FFIBridge.shared.listProjects().map(ProjectEntity.init(from:)) + return all.filter { identifiers.contains($0.id) } + } + + public func suggestedEntities() async throws -> [ProjectEntity] { + try FFIBridge.shared + .listProjects() + .filter { !$0.archived } + .map(ProjectEntity.init(from:)) + } + + public func entities(matching string: String) async throws -> [ProjectEntity] { + let q = string.lowercased() + return try FFIBridge.shared + .listProjects() + .filter { !$0.archived && $0.name.lowercased().contains(q) } + .map(ProjectEntity.init(from:)) + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/TaskEntity.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/TaskEntity.swift new file mode 100644 index 0000000..54ecc8f --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/TaskEntity.swift @@ -0,0 +1,30 @@ +import AppIntents +import Foundation + +public struct TaskEntity: AppEntity, Identifiable, Sendable { + public static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Task") + public static var defaultQuery = TaskQuery() + + public let id: String + public let projectId: String + public let name: String + public let done: Bool + + public var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)", subtitle: "Task") + } + + public init(from dto: TaskDTO) { + self.id = dto.solidtimeId + self.projectId = dto.projectId + self.name = dto.name + self.done = dto.done + } + + public init(id: String, projectId: String, name: String, done: Bool = false) { + self.id = id + self.projectId = projectId + self.name = name + self.done = done + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/TaskQuery.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/TaskQuery.swift new file mode 100644 index 0000000..7e305ce --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/TaskQuery.swift @@ -0,0 +1,26 @@ +import AppIntents +import Foundation + +public struct TaskQuery: EntityQuery, EntityStringQuery { + public init() {} + + public func entities(for identifiers: [TaskEntity.ID]) async throws -> [TaskEntity] { + let all = try FFIBridge.shared.listTasks(projectId: nil).map(TaskEntity.init(from:)) + return all.filter { identifiers.contains($0.id) } + } + + public func suggestedEntities() async throws -> [TaskEntity] { + try FFIBridge.shared + .listTasks(projectId: nil) + .filter { !$0.done } + .map(TaskEntity.init(from:)) + } + + public func entities(matching string: String) async throws -> [TaskEntity] { + let q = string.lowercased() + return try FFIBridge.shared + .listTasks(projectId: nil) + .filter { !$0.done && $0.name.lowercased().contains(q) } + .map(TaskEntity.init(from:)) + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Errors/BridgeError.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Errors/BridgeError.swift new file mode 100644 index 0000000..2c5c9eb --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Errors/BridgeError.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Maps the stable error-code contract in stint-core's FFI envelope into a +/// typed Swift error. App Intent `perform()` bodies throw these; App +/// Intents surfaces the `errorDescription` as the spoken dialog. +/// +/// Codes (do not renumber — public contract): +/// 1 = Invariant (e.g., "a timer is already running") +/// 2 = NotFound (lookup miss) +/// 3 = Conflict (reserved — no current Error variant maps here) +/// 4 = Serialization (malformed JSON across the C ABI) +/// 99 = Internal (any other typed Error variant) +/// -1 = Panic (catch_unwind caught a panic across FFI) +public enum BridgeError: LocalizedError { + case invariant(String) + case notFound(String) + case conflict(String) + case serialization(String) + case `internal`(String) + case panic(String) + + public static func from(code: Int32, message: String) -> BridgeError { + switch code { + case 1: return .invariant(message) + case 2: return .notFound(message) + case 3: return .conflict(message) + case 4: return .serialization(message) + case -1: return .panic(message) + default: return .internal(message) + } + } + + public var errorDescription: String? { + switch self { + case .invariant(let m), .notFound(let m): + return m + case .conflict: + return "That conflicts with an existing entry." + case .serialization: + return "Couldn't read the request." + case .internal: + return "Stint hit an internal error. Check the app." + case .panic: + return "Stint encountered an unexpected error." + } + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Focus/ProjectFocusFilter.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Focus/ProjectFocusFilter.swift new file mode 100644 index 0000000..4b5026e --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Focus/ProjectFocusFilter.swift @@ -0,0 +1,44 @@ +import AppIntents +import Foundation + +/// macOS Focus filter that sets a default project for new Stint timers +/// while a Focus mode is active. +/// +/// `perform()` is called by the OS on every focus activation that has +/// this filter configured. It does NOT fire on deactivation, so we store +/// a (focus_id, project_id) tuple and let `verbs::start` reconcile against +/// the currently-active focus at read time — see the spec's §6.3. +public struct ProjectFocusFilter: SetFocusFilterIntent { + public static var title: LocalizedStringResource = "Default Project" + public static var description = IntentDescription( + "Set a default project for new Stint timers while this focus is on." + ) + + // SetFocusFilterIntent requires all parameters to be optional (Apple's + // contract). If the user leaves the project unset, the filter no-ops. + @Parameter(title: "Project") + public var project: ProjectEntity? + + public init() {} + + public var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "Default project: \(project?.name ?? "—")") + } + + public func perform() async throws -> some IntentResult { + // If the user activated this filter without selecting a project, + // clear any previously-stored default. Otherwise persist a fresh + // (focus_id, project_id) tuple — Rust's verbs::start fallback + // reconciles against focus.last_seen_id at read time. + guard let project = project else { + try? FFIBridge.shared.settingsClear("focus.default_project") + try? FFIBridge.shared.settingsClear("focus.last_seen_id") + return .result() + } + let focusId = UUID().uuidString + let payload = "\(focusId)\t\(project.id)" + try FFIBridge.shared.settingsSet("focus.default_project", payload) + try FFIBridge.shared.settingsSet("focus.last_seen_id", focusId) + return .result() + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Init/StintIntentsInit.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Init/StintIntentsInit.swift new file mode 100644 index 0000000..ab0687f --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Init/StintIntentsInit.swift @@ -0,0 +1,115 @@ +import Foundation + +/// Called once by Rust during Tauri's `setup()` hook. +/// +/// Two responsibilities: +/// +/// 1. **Keep the intent types alive.** Release LTO would otherwise dead- +/// strip the Swift type metadata records for our intent/entity types +/// because no Rust code reaches them — but Apple's App Intents indexer +/// scans the main binary's Mach-O for those exact records to discover +/// discoverable intents. Holding a reference to `.self` of each forces +/// the linker to keep them. +/// 2. Kick off Spotlight bulk refresh + NSUserActivity boot. +@_cdecl("stint_intents_init") +public func stint_intents_init() -> Int32 { + // Anchor the intent + provider + filter type metadata so LTO doesn't + // strip them. We don't actually use the array — just having the + // expression in code that's reachable from an @_cdecl entry point is + // enough. + let anchors: [Any.Type] = [ + StartTimerIntent.self, + StopTimerIntent.self, + GetCurrentIntent.self, + SwitchProjectIntent.self, + LogPastIntent.self, + ListEntriesIntent.self, + ListProjectsIntent.self, + ListTasksIntent.self, + UpdateEntryIntent.self, + DeleteEntryIntent.self, + ProjectFocusFilter.self, + StintAppShortcutsProvider.self, + ProjectEntity.self, + TaskEntity.self, + EntryEntity.self, + ProjectQuery.self, + TaskQuery.self, + EntryQuery.self, + ] + // Force a side effect the compiler can't elide so the anchor array is + // really materialized. Without this `_ = anchors.count` would get + // const-folded and the metadata records dropped again under LTO. + NSLog("StintIntents: anchored %d types", anchors.count) + + SpotlightIndexer.shared.bulkRefresh() + ActivityTracker.shared.boot() + return 0 +} + +/// Called from Rust on every verb mutation + after pull-worker success. +/// +/// Side effects: +/// - Updates the Spotlight index (entry upsert / delete; full project or +/// task refresh). +/// - For entry start/stop/update, also updates the NSUserActivity tracker +/// on the main actor. +@_cdecl("swift_indexer_notify") +public func swift_indexer_notify(_ kind: Int32, _ payloadPtr: UnsafePointer?) { + guard let payloadPtr = payloadPtr else { return } + guard let k = IndexerKind(rawValue: kind) else { return } + let payload = String(cString: payloadPtr) + + switch k { + case .entryStarted: + if let entry = decodeEntry(payload) { + Task { @MainActor in + ActivityTracker.shared.activate(entry: entry) + } + } + case .entryStopped: + Task { @MainActor in + ActivityTracker.shared.invalidate() + } + case .entryUpdated: + if let entry = decodeEntry(payload) { + Task { @MainActor in + ActivityTracker.shared.update(description: entry.entryDescription) + } + } + default: + break + } + + SpotlightIndexer.shared.delta(kind: k, payload: payload) +} + +/// Best-effort macOS Focus identifier accessor. Reads back the +/// `focus.last_seen_id` settings key that `ProjectFocusFilter.perform()` +/// writes — Apple doesn't expose a public "current focus id" API on macOS, +/// so this is our pragmatic proxy. Rust's `verbs::start` fallback +/// reconciles against the same key when picking up the focus default. +@_cdecl("stint_current_focus_id_swift") +public func stint_current_focus_id_swift( + _ out: UnsafeMutablePointer?>? +) -> Int32 { + guard let out = out else { return -2 } + out.pointee = nil + do { + if let id = try FFIBridge.shared.settingsGet("focus.last_seen_id"), + !id.isEmpty { + out.pointee = strdup(id) + } + } catch { + // Best-effort — silently leave nil on lookup failure. + } + return 0 +} + +private func decodeEntry(_ payload: String) -> EntryEntity? { + guard let data = payload.data(using: .utf8), + let dto = try? JSONDecoder().decode(EntryDTO.self, from: data) else { + return nil + } + return EntryEntity(from: dto) +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/DeleteEntryIntent.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/DeleteEntryIntent.swift new file mode 100644 index 0000000..a12bc70 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/DeleteEntryIntent.swift @@ -0,0 +1,17 @@ +import AppIntents +import Foundation + +public struct DeleteEntryIntent: AppIntent { + public static var title: LocalizedStringResource = "Delete Entry" + public static var description = IntentDescription("Delete a Stint time entry.") + + @Parameter(title: "Entry") + public var entry: EntryEntity + + public init() {} + + public func perform() async throws -> some IntentResult & ProvidesDialog { + try FFIBridge.shared.deleteEntry(localUuid: entry.id) + return .result(dialog: "Deleted '\(entry.entryDescription)'.") + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/GetCurrentIntent.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/GetCurrentIntent.swift new file mode 100644 index 0000000..6aefb2f --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/GetCurrentIntent.swift @@ -0,0 +1,17 @@ +import AppIntents +import Foundation + +public struct GetCurrentIntent: AppIntent { + public static var title: LocalizedStringResource = "Current Timer" + public static var description = IntentDescription("Show the currently running Stint timer.") + + public init() {} + + public func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + guard let entry = try FFIBridge.shared.current() else { + return .result(value: nil, dialog: "No active timer.") + } + let entity = EntryEntity(from: entry) + return .result(value: entity, dialog: "You're tracking '\(entry.description)'.") + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/ListEntriesIntent.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/ListEntriesIntent.swift new file mode 100644 index 0000000..c316329 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/ListEntriesIntent.swift @@ -0,0 +1,33 @@ +import AppIntents +import Foundation + +public struct ListEntriesIntent: AppIntent { + public static var title: LocalizedStringResource = "List Entries" + public static var description = IntentDescription("Fetch Stint time entries.") + + @Parameter(title: "Since") + public var since: Date? + + @Parameter(title: "Until") + public var until: Date? + + @Parameter(title: "Project") + public var project: ProjectEntity? + + @Parameter(title: "Limit", default: 100) + public var limit: Int + + public init() {} + + public func perform() async throws -> some IntentResult & ReturnsValue<[EntryEntity]> { + let fmt = ISO8601DateFormatter() + let filter = EntryFilter( + since: since.map { fmt.string(from: $0) }, + until: until.map { fmt.string(from: $0) }, + projectId: project?.id, + limit: UInt32(max(0, limit)) + ) + let entries = try FFIBridge.shared.listEntries(filter).map(EntryEntity.init(from:)) + return .result(value: entries) + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/ListProjectsIntent.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/ListProjectsIntent.swift new file mode 100644 index 0000000..1cf6bc4 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/ListProjectsIntent.swift @@ -0,0 +1,13 @@ +import AppIntents + +public struct ListProjectsIntent: AppIntent { + public static var title: LocalizedStringResource = "List Projects" + public static var description = IntentDescription("Fetch the list of Stint projects.") + + public init() {} + + public func perform() async throws -> some IntentResult & ReturnsValue<[ProjectEntity]> { + let projects = try FFIBridge.shared.listProjects().map(ProjectEntity.init(from:)) + return .result(value: projects) + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/ListTasksIntent.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/ListTasksIntent.swift new file mode 100644 index 0000000..6784617 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/ListTasksIntent.swift @@ -0,0 +1,18 @@ +import AppIntents + +public struct ListTasksIntent: AppIntent { + public static var title: LocalizedStringResource = "List Tasks" + public static var description = IntentDescription("Fetch Stint tasks for a project.") + + @Parameter(title: "Project") + public var project: ProjectEntity + + public init() {} + + public func perform() async throws -> some IntentResult & ReturnsValue<[TaskEntity]> { + let tasks = try FFIBridge.shared + .listTasks(projectId: project.id) + .map(TaskEntity.init(from:)) + return .result(value: tasks) + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/LogPastIntent.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/LogPastIntent.swift new file mode 100644 index 0000000..8c71793 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/LogPastIntent.swift @@ -0,0 +1,43 @@ +import AppIntents +import Foundation + +public struct LogPastIntent: AppIntent { + public static var title: LocalizedStringResource = "Log Past Work" + public static var description = IntentDescription("Retroactively log a past duration in Stint.") + + @Parameter(title: "Duration") + public var duration: Measurement + + @Parameter(title: "Description", default: "Untitled") + public var entryDescription: String + + @Parameter(title: "Project") + public var project: ProjectEntity? + + public init() {} + + public func perform() async throws -> some IntentResult & ProvidesDialog { + let seconds = duration.converted(to: .seconds).value + let startDate = Date(timeIntervalSinceNow: -seconds) + let fmt = ISO8601DateFormatter() + + // Stop any running timer first so the backdated entry doesn't overlap. + if (try? FFIBridge.shared.current()) != nil { + _ = try? FFIBridge.shared.stop() + } + + _ = try FFIBridge.shared.start( + StartParams( + description: entryDescription, + projectId: project?.id, + startAt: fmt.string(from: startDate), + source: "intent" + ) + ) + _ = try FFIBridge.shared.stop() + + let mins = Int(duration.converted(to: .minutes).value) + let projectName = project?.name ?? "no project" + return .result(dialog: "Logged \(mins) minutes on \(projectName).") + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/StartTimerIntent.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/StartTimerIntent.swift new file mode 100644 index 0000000..18da0a6 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/StartTimerIntent.swift @@ -0,0 +1,28 @@ +import AppIntents +import Foundation + +public struct StartTimerIntent: AppIntent { + public static var title: LocalizedStringResource = "Start Timer" + public static var description = IntentDescription("Start tracking time in Stint.") + + @Parameter(title: "Description", requestValueDialog: "What are you working on?") + public var entryDescription: String + + @Parameter(title: "Project") + public var project: ProjectEntity? + + public init() {} + + public func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + let entry = try FFIBridge.shared.start( + StartParams( + description: entryDescription, + projectId: project?.id, + source: "intent" + ) + ) + let entity = EntryEntity(from: entry) + let projectName = project?.name ?? "no project" + return .result(value: entity, dialog: "Tracking '\(entryDescription)' on \(projectName).") + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/StopTimerIntent.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/StopTimerIntent.swift new file mode 100644 index 0000000..07a29c7 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/StopTimerIntent.swift @@ -0,0 +1,17 @@ +import AppIntents +import Foundation + +public struct StopTimerIntent: AppIntent { + public static var title: LocalizedStringResource = "Stop Timer" + public static var description = IntentDescription("Stop the running Stint timer.") + + public init() {} + + public func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + let entry = try FFIBridge.shared.stop() + let entity = EntryEntity(from: entry) + let mins = Int(entity.duration.converted(to: .minutes).value) + let projectLabel = entry.projectId.map { "project \($0)" } ?? "no project" + return .result(value: entity, dialog: "Stopped. \(mins) minutes on \(projectLabel).") + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/SwitchProjectIntent.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/SwitchProjectIntent.swift new file mode 100644 index 0000000..ffb0ba7 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/SwitchProjectIntent.swift @@ -0,0 +1,27 @@ +import AppIntents +import Foundation + +public struct SwitchProjectIntent: AppIntent { + public static var title: LocalizedStringResource = "Switch Project" + public static var description = IntentDescription("Stop the current Stint timer and start a new one on a different project.") + + @Parameter(title: "Project") + public var project: ProjectEntity + + public init() {} + + public func perform() async throws -> some IntentResult & ProvidesDialog { + guard let current = try FFIBridge.shared.current() else { + throw BridgeError.invariant("No timer to switch from.") + } + _ = try FFIBridge.shared.stop() + _ = try FFIBridge.shared.start( + StartParams( + description: current.description, + projectId: project.id, + source: "intent" + ) + ) + return .result(dialog: "Switched to \(project.name).") + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/UpdateEntryIntent.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/UpdateEntryIntent.swift new file mode 100644 index 0000000..fa14374 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/UpdateEntryIntent.swift @@ -0,0 +1,30 @@ +import AppIntents +import Foundation + +public struct UpdateEntryIntent: AppIntent { + public static var title: LocalizedStringResource = "Update Entry" + public static var description = IntentDescription("Update fields on a Stint time entry.") + + @Parameter(title: "Entry") + public var entry: EntryEntity + + @Parameter(title: "Description") + public var entryDescription: String? + + @Parameter(title: "Project") + public var project: ProjectEntity? + + @Parameter(title: "Billable") + public var billable: Bool? + + public init() {} + + public func perform() async throws -> some IntentResult & ReturnsValue { + var patch = EntryPatch() + if let d = entryDescription { patch.description = d } + if let p = project { patch.projectId = .set(p.id) } + if let b = billable { patch.billable = b } + let updated = try FFIBridge.shared.updateEntry(localUuid: entry.id, patch: patch) + return .result(value: EntryEntity(from: updated)) + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Shortcuts/PhraseStrings.xcstrings b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Shortcuts/PhraseStrings.xcstrings new file mode 100644 index 0000000..00ebfd3 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Shortcuts/PhraseStrings.xcstrings @@ -0,0 +1,6 @@ +{ + "sourceLanguage" : "en", + "strings" : { + }, + "version" : "1.0" +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Shortcuts/StintAppShortcutsProvider.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Shortcuts/StintAppShortcutsProvider.swift new file mode 100644 index 0000000..e68e868 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Shortcuts/StintAppShortcutsProvider.swift @@ -0,0 +1,73 @@ +import AppIntents + +/// The 5 curated App Shortcuts. Each phrase MUST contain +/// `\(.applicationName)` — appintentsmetadataprocessor rejects the build +/// otherwise. Phrases are a public contract: renaming them breaks any +/// voice shortcuts users have recorded. +/// +/// **First-party-app collision avoidance.** Siri's NLU resolves voice +/// phrases against first-party app shortcuts (Clock, Reminders, …) before +/// reaching third-party ones. "Start timer" and "Stop timer" both belong +/// to Clock and will hijack the request even when the user says "in Stint" +/// after. Our phrases deliberately: +/// - Use "tracking" instead of "timer" in the verb position +/// - Lead with the app name when the alternative phrasing isn't unique +/// ("Stint start", "Stint stop") so Siri's first-token match wins +public struct StintAppShortcutsProvider: AppShortcutsProvider { + public static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StartTimerIntent(), + phrases: [ + "Start tracking in \(.applicationName)", + "Track time in \(.applicationName)", + "\(.applicationName) start tracking", + "Track \(\.$project) in \(.applicationName)", + ], + shortTitle: "Start Tracking", + systemImageName: "play.circle.fill" + ) + AppShortcut( + intent: StopTimerIntent(), + phrases: [ + "Stop tracking in \(.applicationName)", + "\(.applicationName) stop tracking", + "End \(.applicationName) tracking", + ], + shortTitle: "Stop Tracking", + systemImageName: "stop.circle.fill" + ) + AppShortcut( + intent: GetCurrentIntent(), + phrases: [ + "What am I tracking in \(.applicationName)", + "\(.applicationName) current tracking", + "Show \(.applicationName) status", + ], + shortTitle: "Current Tracking", + systemImageName: "clock" + ) + AppShortcut( + intent: SwitchProjectIntent(), + phrases: [ + "Switch to \(\.$project) in \(.applicationName)", + "\(.applicationName) switch to \(\.$project)", + ], + shortTitle: "Switch Project", + systemImageName: "arrow.triangle.swap" + ) + // LogPastIntent's `duration` parameter is a Measurement; + // App Shortcut phrases only allow AppEntity / AppEnum placeholders, + // not Measurement. So this App Shortcut just opens the intent's + // configuration dialog where the user fills in duration manually. + AppShortcut( + intent: LogPastIntent(), + phrases: [ + "Log past work in \(.applicationName)", + "\(.applicationName) log past work", + "Log last meeting in \(.applicationName)", + ], + shortTitle: "Log Past Work", + systemImageName: "backward.circle" + ) + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Spotlight/ActivityTracker.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Spotlight/ActivityTracker.swift new file mode 100644 index 0000000..a123635 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Spotlight/ActivityTracker.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Maintains an NSUserActivity for the currently-running timer so Spotlight +/// shows it as a "live" tile and handoff is eligible. +public final class ActivityTracker: @unchecked Sendable { + public static let shared = ActivityTracker() + + private static let activityType = "tech.reyem.stint.tracking" + + private var current: NSUserActivity? + + public init() {} + + /// Called once at framework init. Queries stint-core for any + /// currently-running entry and activates an NSUserActivity for it. + public func boot() { + Task.detached(priority: .background) { + do { + if let entry = try FFIBridge.shared.current() { + let entity = EntryEntity(from: entry) + await MainActor.run { + Self.shared.activate(entry: entity) + } + } + } catch { + FFIBridge.shared.logWarn("activitytracker boot failed: \(error)") + } + } + } + + @MainActor + public func activate(entry: EntryEntity) { + let activity = NSUserActivity(activityType: Self.activityType) + activity.title = "Tracking: \(entry.entryDescription)" + activity.userInfo = ["uuid": entry.id] + // webpageURL is what Spotlight + Handoff dispatch when the activity + // is selected. Setting it to our deep-link scheme makes the existing + // tauri-plugin-deep-link handler in stint-app/src/main.rs route to + // the entry. Without this, macOS just launches the app with the + // activity attached but no obvious dispatch target on the Tauri/Rust + // side. + activity.webpageURL = URL(string: "stint://entry/\(entry.id)") + activity.isEligibleForSearch = true + activity.isEligibleForHandoff = true + // NSUserActivity.isEligibleForPrediction is iOS-only; no macOS equivalent. + activity.becomeCurrent() + self.current = activity + } + + @MainActor + public func update(description: String) { + current?.title = "Tracking: \(description)" + } + + @MainActor + public func invalidate() { + current?.invalidate() + current = nil + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/Spotlight/SpotlightIndexer.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Spotlight/SpotlightIndexer.swift new file mode 100644 index 0000000..0628126 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/Spotlight/SpotlightIndexer.swift @@ -0,0 +1,192 @@ +import CoreSpotlight +import Foundation +import UniformTypeIdentifiers + +/// Mirrors the Rust IndexerKind contract — see stint_core::ffi::IndexerKind. +public enum IndexerKind: Int32 { + case entryStarted = 1 + case entryStopped = 2 + case entryUpdated = 3 + case entryDeleted = 4 + case projectsReplaced = 5 + case tasksReplaced = 6 +} + +/// Maintains the CSSearchableIndex for entries / projects / tasks. +/// +/// - **Bulk refresh** on app launch (uses `indexSearchableItems` which has +/// upsert semantics on `uniqueIdentifier` — no delete-first needed). +/// - **Delta updates** triggered by Rust verb call sites via the +/// `swift_indexer_notify` @_cdecl symbol. Each update dispatches to a +/// background queue so the Rust caller isn't blocked. +public final class SpotlightIndexer: @unchecked Sendable { + public static let shared = SpotlightIndexer() + + private static let entryDomain = "tech.reyem.stint.entry" + private static let projectDomain = "tech.reyem.stint.project" + private static let taskDomain = "tech.reyem.stint.task" + + private let bridge: Bridge + + public init(bridge: Bridge = FFIBridge.shared) { + self.bridge = bridge + } + + // MARK: - Public API + + /// Re-fetch every entry/project/task from stint-core and reindex. + /// Idempotent: existing items with matching uniqueIdentifier are upserted. + /// + /// Uses `DispatchQueue.global()` rather than `Task.detached` because + /// Swift's structured concurrency runtime is brittle when initialized + /// from a non-Swift host binary (Tauri-managed Rust runtime); detached + /// tasks sometimes never schedule. DispatchQueue is plain-old-GCD and + /// reliably runs. + public func bulkRefresh() { + DispatchQueue.global(qos: .background).async { [self] in + refreshEntries() + refreshProjects() + refreshTasks() + } + } + + /// Apply a delta the Rust side pushed in. Decodes the payload per kind + /// and dispatches the index/delete call to a background queue. + public func delta(kind: IndexerKind, payload: String) { + DispatchQueue.global(qos: .background).async { [self] in + do { + switch kind { + case .entryStarted, .entryStopped, .entryUpdated: + let dto = try JSONDecoder().decode(EntryDTO.self, from: Data(payload.utf8)) + upsertEntry(EntryEntity(from: dto)) + case .entryDeleted: + struct P: Decodable { let local_uuid: String } + let p = try JSONDecoder().decode(P.self, from: Data(payload.utf8)) + deleteEntry(localUuid: p.local_uuid) + case .projectsReplaced: + refreshProjects() + case .tasksReplaced: + refreshTasks() + } + } catch { + bridge.logWarn("spotlight delta decode failed: \(error)") + } + } + } + + // MARK: - Entries + + private func refreshEntries() { + do { + let entries = try bridge.listEntries(EntryFilter(limit: nil)) + .map(EntryEntity.init(from:)) + let items = entries.map(makeEntryItem) + CSSearchableIndex.default().indexSearchableItems(items) { [bridge] error in + if let error = error { + bridge.logWarn("spotlight refreshEntries failed: \(error)") + } + } + } catch { + bridge.logWarn("spotlight refreshEntries fetch failed: \(error)") + } + } + + public func upsertEntry(_ entry: EntryEntity) { + let item = makeEntryItem(entry) + CSSearchableIndex.default().indexSearchableItems([item]) { [bridge] error in + if let error = error { + bridge.logWarn("spotlight upsertEntry failed: \(error)") + } + } + } + + public func deleteEntry(localUuid: String) { + CSSearchableIndex.default() + .deleteSearchableItems(withIdentifiers: [localUuid]) { [bridge] error in + if let error = error { + bridge.logWarn("spotlight deleteEntry failed: \(error)") + } + } + } + + public func makeEntryItem(_ entry: EntryEntity) -> CSSearchableItem { + let attrs = CSSearchableItemAttributeSet(contentType: UTType.text) + attrs.title = entry.entryDescription + let mins = Int(entry.duration.converted(to: .minutes).value) + let fmt = DateFormatter() + fmt.dateStyle = .medium + fmt.timeStyle = .short + attrs.contentDescription = "\(fmt.string(from: entry.startAt)) · \(mins)m" + attrs.keywords = ["stint", "timer"] + if let projectId = entry.projectId { + attrs.containerIdentifier = projectId + } + // Tap → open via stint:// URL scheme. tauri-plugin-deep-link + // routes stint://entry/ to the OpenEntry action in + // stint-app/src/main.rs. + attrs.url = URL(string: "stint://entry/\(entry.id)") + return CSSearchableItem( + uniqueIdentifier: entry.id, + domainIdentifier: Self.entryDomain, + attributeSet: attrs + ) + } + + // MARK: - Projects + + private func refreshProjects() { + do { + let projects = try bridge.listProjects() + let items = projects.map(makeProjectItem) + CSSearchableIndex.default().indexSearchableItems(items) { [bridge] error in + if let error = error { + bridge.logWarn("spotlight refreshProjects failed: \(error)") + } + } + } catch { + bridge.logWarn("spotlight refreshProjects fetch failed: \(error)") + } + } + + public func makeProjectItem(_ project: ProjectDTO) -> CSSearchableItem { + let attrs = CSSearchableItemAttributeSet(contentType: UTType.text) + attrs.title = project.name + attrs.contentDescription = "Project" + attrs.keywords = ["stint", "project", project.name] + attrs.url = URL(string: "stint://project/\(project.solidtimeId)") + return CSSearchableItem( + uniqueIdentifier: project.solidtimeId, + domainIdentifier: Self.projectDomain, + attributeSet: attrs + ) + } + + // MARK: - Tasks + + private func refreshTasks() { + do { + let tasks = try bridge.listTasks(projectId: nil) + let items = tasks.map(makeTaskItem) + CSSearchableIndex.default().indexSearchableItems(items) { [bridge] error in + if let error = error { + bridge.logWarn("spotlight refreshTasks failed: \(error)") + } + } + } catch { + bridge.logWarn("spotlight refreshTasks fetch failed: \(error)") + } + } + + public func makeTaskItem(_ task: TaskDTO) -> CSSearchableItem { + let attrs = CSSearchableItemAttributeSet(contentType: UTType.text) + attrs.title = task.name + attrs.contentDescription = "Task in project \(task.projectId)" + attrs.keywords = ["stint", "task", task.name] + attrs.url = URL(string: "stint://task/\(task.solidtimeId)") + return CSSearchableItem( + uniqueIdentifier: task.solidtimeId, + domainIdentifier: Self.taskDomain, + attributeSet: attrs + ) + } +} diff --git a/crates/stint-app/swift/StintIntents/Sources/StintIntents/WidgetCount.swift b/crates/stint-app/swift/StintIntents/Sources/StintIntents/WidgetCount.swift new file mode 100644 index 0000000..5ef647a --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Sources/StintIntents/WidgetCount.swift @@ -0,0 +1,24 @@ +import Foundation +import WidgetKit + +/// Returns the number of configured Stint widgets, or -1 on error. +/// Called from Rust via dlsym at GUI startup to decide whether to +/// auto-enable the loopback HTTP API. +/// +/// Lives in the StintIntents framework (loaded by stint-app at launch) +/// rather than the StintWidget.appex (separate process) — only the +/// framework path is dlsym-reachable from the main binary. +@_cdecl("stint_widget_count") +public func stint_widget_count() -> Int32 { + let kindFilter = "tech.reyem.stint.widget" + let semaphore = DispatchSemaphore(value: 0) + var result: Int32 = -1 + WidgetCenter.shared.getCurrentConfigurations { res in + if case .success(let widgets) = res { + result = Int32(widgets.filter { $0.kind == kindFilter }.count) + } + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + .seconds(2)) + return result +} diff --git a/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/BridgeErrorTests.swift b/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/BridgeErrorTests.swift new file mode 100644 index 0000000..da1a3f4 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/BridgeErrorTests.swift @@ -0,0 +1,65 @@ +import Testing +@testable import StintIntents + +@Suite("BridgeError envelope code mapping") +struct BridgeErrorTests { + @Test func invariantMapsToCode1() { + let err = BridgeError.from(code: 1, message: "timer already running") + if case .invariant(let m) = err { + #expect(m == "timer already running") + } else { + Issue.record("expected .invariant, got \(err)") + } + #expect(err.errorDescription == "timer already running") + } + + @Test func notFoundMapsToCode2() { + let err = BridgeError.from(code: 2, message: "no such uuid") + if case .notFound(let m) = err { + #expect(m == "no such uuid") + } else { + Issue.record("expected .notFound") + } + #expect(err.errorDescription == "no such uuid") + } + + @Test func conflictMapsToCode3() { + let err = BridgeError.from(code: 3, message: "overlap") + if case .conflict = err { + } else { + Issue.record("expected .conflict") + } + #expect(err.errorDescription == "That conflicts with an existing entry.") + } + + @Test func serializationMapsToCode4() { + let err = BridgeError.from(code: 4, message: "bad json") + if case .serialization = err { + } else { + Issue.record("expected .serialization") + } + #expect(err.errorDescription == "Couldn't read the request.") + } + + @Test func panicMapsToNegative1() { + let err = BridgeError.from(code: -1, message: "rust panic") + if case .panic = err { + } else { + Issue.record("expected .panic") + } + #expect(err.errorDescription == "Stint encountered an unexpected error.") + } + + @Test func unknownCodeMapsToInternal() { + let err = BridgeError.from(code: 99, message: "unknown") + if case .internal = err { + } else { + Issue.record("expected .internal") + } + let err2 = BridgeError.from(code: 7777, message: "other") + if case .internal = err2 { + } else { + Issue.record("expected .internal for unknown code") + } + } +} diff --git a/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/EntityCodingTests.swift b/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/EntityCodingTests.swift new file mode 100644 index 0000000..5087138 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/EntityCodingTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import StintIntents + +@Suite("Entity DTO decoding from Rust JSON shapes") +struct EntityCodingTests { + private func decode(_ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) + } + + @Test func entryDTODecodes() throws { + let json = """ + { + "local_uuid": "u1", + "solidtime_id": null, + "description": "writing tests", + "project_id": "p1", + "task_id": null, + "billable": true, + "start_at": "2026-05-25T10:00:00Z", + "end_at": "2026-05-25T11:00:00Z", + "source": "test" + } + """ + let dto: EntryDTO = try decode(json) + #expect(dto.localUuid == "u1") + #expect(dto.description == "writing tests") + #expect(dto.projectId == "p1") + #expect(dto.taskId == nil) + #expect(dto.billable == true) + #expect(dto.endAt == "2026-05-25T11:00:00Z") + } + + @Test func projectDTODecodes() throws { + let json = #"{"solidtime_id":"p1","name":"Acme","color":null,"client_id":null,"archived":false}"# + let dto: ProjectDTO = try decode(json) + #expect(dto.solidtimeId == "p1") + #expect(dto.name == "Acme") + #expect(dto.archived == false) + } + + @Test func taskDTODecodes() throws { + let json = #"{"solidtime_id":"t1","project_id":"p1","name":"Fix bug","done":false}"# + let dto: TaskDTO = try decode(json) + #expect(dto.solidtimeId == "t1") + #expect(dto.projectId == "p1") + #expect(dto.name == "Fix bug") + #expect(dto.done == false) + } + + @Test func entryEntityComputesDurationFromDTO() { + let dto = EntryDTO( + localUuid: "u1", + solidtimeId: nil, + description: "x", + projectId: nil, + taskId: nil, + billable: false, + startAt: "2026-05-25T10:00:00Z", + endAt: "2026-05-25T10:30:00Z", + source: "test" + ) + let entity = EntryEntity(from: dto) + let mins = Int(entity.duration.converted(to: .minutes).value) + #expect(mins == 30) + } + + @Test func entryEntityHandlesRunningTimerEndAtNil() { + let dto = EntryDTO( + localUuid: "u1", + solidtimeId: nil, + description: "running", + projectId: nil, + taskId: nil, + billable: false, + startAt: "2026-05-25T10:00:00Z", + endAt: nil, + source: "test" + ) + let entity = EntryEntity(from: dto) + // Duration is computed from now; just verify it's non-negative and bounded. + let secs = entity.duration.converted(to: .seconds).value + #expect(secs >= 0) + #expect(entity.endAt == nil) + } +} diff --git a/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/PatchEncodingTests.swift b/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/PatchEncodingTests.swift new file mode 100644 index 0000000..1f09a87 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/PatchEncodingTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import StintIntents + +@Suite("EntryPatch 3-way nullable encoding") +struct PatchEncodingTests { + private func encodeJSON(_ value: T) throws -> String { + let data = try JSONEncoder().encode(value) + return String(decoding: data, as: UTF8.self) + } + + @Test func unchangedFieldIsAbsent() throws { + let patch = EntryPatch() + let json = try encodeJSON(patch) + #expect(!json.contains("project_id")) + #expect(!json.contains("task_id")) + #expect(!json.contains("end_at")) + } + + @Test func clearProjectIdEncodesAsNull() throws { + let patch = EntryPatch(projectId: .clear) + let json = try encodeJSON(patch) + #expect(json.contains("\"project_id\":null")) + } + + @Test func setProjectIdEncodesAsValue() throws { + let patch = EntryPatch(projectId: .set("p1")) + let json = try encodeJSON(patch) + #expect(json.contains("\"project_id\":\"p1\"")) + } + + @Test func descriptionSetEncodesPlain() throws { + let patch = EntryPatch(description: "new desc") + let json = try encodeJSON(patch) + #expect(json.contains("\"description\":\"new desc\"")) + } + + @Test func multipleFieldsCombine() throws { + let patch = EntryPatch( + description: "d", + projectId: .set("p1"), + taskId: .clear, + billable: true, + endAt: .set("2026-05-25T11:00:00Z") + ) + let json = try encodeJSON(patch) + #expect(json.contains("\"description\":\"d\"")) + #expect(json.contains("\"project_id\":\"p1\"")) + #expect(json.contains("\"task_id\":null")) + #expect(json.contains("\"billable\":true")) + #expect(json.contains("\"end_at\":\"2026-05-25T11:00:00Z\"")) + } +} diff --git a/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/SpotlightSchemaTests.swift b/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/SpotlightSchemaTests.swift new file mode 100644 index 0000000..8c9718f --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/SpotlightSchemaTests.swift @@ -0,0 +1,56 @@ +import CoreSpotlight +import Foundation +import Testing +@testable import StintIntents + +@Suite("CSSearchableItem schema for Spotlight indexing") +struct SpotlightSchemaTests { + @Test func entryItemHasCorrectDomainAndIdentifiers() { + let dto = EntryDTO( + localUuid: "u1", + solidtimeId: nil, + description: "client meeting", + projectId: "p1", + taskId: nil, + billable: true, + startAt: "2026-05-25T10:00:00Z", + endAt: "2026-05-25T11:00:00Z", + source: "test" + ) + let item = SpotlightIndexer.shared.makeEntryItem(EntryEntity(from: dto)) + #expect(item.uniqueIdentifier == "u1") + #expect(item.domainIdentifier == "tech.reyem.stint.entry") + #expect(item.attributeSet.title == "client meeting") + #expect(item.attributeSet.keywords?.contains("stint") == true) + } + + @Test func projectItemHasCorrectDomainAndIdentifiers() { + let dto = ProjectDTO( + solidtimeId: "p1", + name: "Acme", + color: nil, + clientId: nil, + archived: false + ) + let item = SpotlightIndexer.shared.makeProjectItem(dto) + #expect(item.uniqueIdentifier == "p1") + #expect(item.domainIdentifier == "tech.reyem.stint.project") + #expect(item.attributeSet.title == "Acme") + #expect(item.attributeSet.keywords?.contains("project") == true) + #expect(item.attributeSet.keywords?.contains("Acme") == true) + } + + @Test func taskItemHasCorrectDomainAndIdentifiers() { + let dto = TaskDTO( + solidtimeId: "t1", + projectId: "p1", + name: "Fix bug", + done: false + ) + let item = SpotlightIndexer.shared.makeTaskItem(dto) + #expect(item.uniqueIdentifier == "t1") + #expect(item.domainIdentifier == "tech.reyem.stint.task") + #expect(item.attributeSet.title == "Fix bug") + #expect(item.attributeSet.keywords?.contains("task") == true) + } +} diff --git a/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/StubFFI.swift b/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/StubFFI.swift new file mode 100644 index 0000000..b3fb696 --- /dev/null +++ b/crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/StubFFI.swift @@ -0,0 +1,94 @@ +import Foundation + +// Stub implementations of the stint-core C ABI for unit tests. The +// production framework uses @_silgen_name forward declarations resolved +// at app-load time against libstint_core; in the test bundle there is +// no host process providing those symbols, so the test target ships +// these no-op stubs to satisfy the dynamic loader. +// +// Tests that exercise actual FFI behavior would need to be integration +// tests linking against real libstint_core — that's outside the scope +// of these unit tests, which focus on pure-Swift logic (envelope +// decoding, entity coding, Spotlight schema construction). + +@_cdecl("stint_free_string") +func stub_stint_free_string(_ ptr: UnsafeMutablePointer?) { + // No-op: production frees via CString::from_raw; stubs don't allocate. + _ = ptr +} + +@_cdecl("stint_verb_start") +func stub_stint_verb_start(_ params: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 { + if let out = out { out.pointee = nil } + return -2 +} + +@_cdecl("stint_verb_stop") +func stub_stint_verb_stop(_ out: UnsafeMutablePointer?>?) -> Int32 { + if let out = out { out.pointee = nil } + return -2 +} + +@_cdecl("stint_verb_current") +func stub_stint_verb_current(_ out: UnsafeMutablePointer?>?) -> Int32 { + if let out = out { out.pointee = nil } + return -2 +} + +@_cdecl("stint_verb_list_entries") +func stub_stint_verb_list_entries(_ filter: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 { + if let out = out { out.pointee = nil } + return -2 +} + +@_cdecl("stint_verb_list_projects") +func stub_stint_verb_list_projects(_ out: UnsafeMutablePointer?>?) -> Int32 { + if let out = out { out.pointee = nil } + return -2 +} + +@_cdecl("stint_verb_list_tasks") +func stub_stint_verb_list_tasks(_ params: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 { + if let out = out { out.pointee = nil } + return -2 +} + +@_cdecl("stint_verb_update_entry") +func stub_stint_verb_update_entry(_ params: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 { + if let out = out { out.pointee = nil } + return -2 +} + +@_cdecl("stint_verb_delete_entry") +func stub_stint_verb_delete_entry(_ params: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 { + if let out = out { out.pointee = nil } + return -2 +} + +@_cdecl("stint_settings_set") +func stub_stint_settings_set(_ key: UnsafePointer?, _ value: UnsafePointer?) -> Int32 { + return -2 +} + +@_cdecl("stint_settings_get") +func stub_stint_settings_get(_ key: UnsafePointer?, _ out: UnsafeMutablePointer?>?) -> Int32 { + if let out = out { out.pointee = nil } + return -2 +} + +@_cdecl("stint_settings_clear") +func stub_stint_settings_clear(_ key: UnsafePointer?) -> Int32 { + return -2 +} + +@_cdecl("stint_log_warn") +func stub_stint_log_warn(_ msg: UnsafePointer?) { + // No-op + _ = msg +} + +@_cdecl("stint_current_focus_id") +func stub_stint_current_focus_id(_ out: UnsafeMutablePointer?>?) -> Int32 { + if let out = out { out.pointee = nil } + return 0 +} diff --git a/crates/stint-app/swift/StintWidget/.gitignore b/crates/stint-app/swift/StintWidget/.gitignore new file mode 100644 index 0000000..3210a44 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/.gitignore @@ -0,0 +1,3 @@ +.build/ +build/ +.swiftpm/ diff --git a/crates/stint-app/swift/StintWidget/Package.swift b/crates/stint-app/swift/StintWidget/Package.swift new file mode 100644 index 0000000..811a209 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "StintWidget", + platforms: [.macOS(.v14)], + products: [ + .executable(name: "StintWidget", targets: ["StintWidget"]), + ], + targets: [ + .executableTarget( + name: "StintWidget", + path: "Sources/StintWidget" + ), + .testTarget( + name: "StintWidgetTests", + dependencies: ["StintWidget"], + path: "Tests/StintWidgetTests" + ), + ] +) diff --git a/crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/EntryDTO.swift b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/EntryDTO.swift new file mode 100644 index 0000000..76b0cdc --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/EntryDTO.swift @@ -0,0 +1,13 @@ +import Foundation + +struct EntryDTO: Codable { + let local_uuid: String + let solidtime_id: String? + let description: String + let project_id: String? + let task_id: String? + let billable: Bool + let start_at: String + let end_at: String? + let source: String +} diff --git a/crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/PortDiscovery.swift b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/PortDiscovery.swift new file mode 100644 index 0000000..a8e1935 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/PortDiscovery.swift @@ -0,0 +1,24 @@ +import Foundation + +enum PortDiscoveryError: Error { + case fileNotFound + case unreadable + case parseError +} + +struct PortDiscovery { + static var defaultPath: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("stint/api.port") + } + + static func read(from url: URL = defaultPath) throws -> UInt16 { + guard FileManager.default.fileExists(atPath: url.path) else { throw PortDiscoveryError.fileNotFound } + guard let data = try? Data(contentsOf: url), + let s = String(data: data, encoding: .utf8) else { throw PortDiscoveryError.unreadable } + guard let port = UInt16(s.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw PortDiscoveryError.parseError + } + return port + } +} diff --git a/crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/ProjectDTO.swift b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/ProjectDTO.swift new file mode 100644 index 0000000..c043079 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/ProjectDTO.swift @@ -0,0 +1,9 @@ +import Foundation + +struct ProjectDTO: Codable { + let solidtime_id: String + let name: String + let color: String? + let client_id: String? + let archived: Bool +} diff --git a/crates/stint-app/swift/StintWidget/Sources/StintWidget/Provider.swift b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Provider.swift new file mode 100644 index 0000000..32e47bb --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Provider.swift @@ -0,0 +1,72 @@ +import WidgetKit +import AppIntents +import Foundation + +struct StintTimelineEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot +} + +enum WidgetSnapshot { + case unavailable + case runningTimer(description: String, projectName: String?, elapsedSecs: TimeInterval) + case idleTimer + case todayTotal(seconds: TimeInterval, byProject: [(name: String, seconds: TimeInterval)]) + case weekProject(projectName: String, seconds: TimeInterval, byDay: [TimeInterval]) +} + +struct StintProvider: AppIntentTimelineProvider { + typealias Entry = StintTimelineEntry + typealias Intent = WidgetConfigIntent + + func placeholder(in context: Context) -> StintTimelineEntry { + StintTimelineEntry(date: Date(), snapshot: .runningTimer(description: "Loading…", projectName: nil, elapsedSecs: 0)) + } + + func snapshot(for configuration: WidgetConfigIntent, in context: Context) async -> StintTimelineEntry { + await fetchOne(configuration: configuration) + } + + func timeline(for configuration: WidgetConfigIntent, in context: Context) async -> Timeline { + let snapshot = await fetchSnapshot(configuration: configuration) + let now = Date() + switch snapshot { + case .runningTimer: + var entries: [StintTimelineEntry] = [] + for i in 0..<60 { + entries.append(StintTimelineEntry(date: now.addingTimeInterval(TimeInterval(i * 60)), snapshot: snapshot)) + } + return Timeline(entries: entries, policy: .atEnd) + default: + return Timeline(entries: [StintTimelineEntry(date: now, snapshot: snapshot)], policy: .after(now.addingTimeInterval(300))) + } + } + + private func fetchSnapshot(configuration: WidgetConfigIntent) async -> WidgetSnapshot { + guard let port = try? PortDiscovery.read() else { return .unavailable } + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/v1/current")!) + request.timeoutInterval = 2 + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + return .unavailable + } + if data.count <= 4, let str = String(data: data, encoding: .utf8), str.trimmingCharacters(in: .whitespacesAndNewlines) == "null" { + return .idleTimer + } + let entry = try JSONDecoder().decode(EntryDTO.self, from: data) + let start = ISO8601DateFormatter().date(from: entry.start_at) ?? Date() + return .runningTimer( + description: entry.description, + projectName: entry.project_id, + elapsedSecs: Date().timeIntervalSince(start) + ) + } catch { + return .unavailable + } + } + + private func fetchOne(configuration: WidgetConfigIntent) async -> StintTimelineEntry { + StintTimelineEntry(date: Date(), snapshot: await fetchSnapshot(configuration: configuration)) + } +} diff --git a/crates/stint-app/swift/StintWidget/Sources/StintWidget/RunningTimerWidget.swift b/crates/stint-app/swift/StintWidget/Sources/StintWidget/RunningTimerWidget.swift new file mode 100644 index 0000000..76258c7 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Sources/StintWidget/RunningTimerWidget.swift @@ -0,0 +1,31 @@ +import WidgetKit +import SwiftUI + +struct RunningTimerWidget: Widget { + let kind: String = "tech.reyem.stint.widget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: WidgetConfigIntent.self, provider: StintProvider()) { entry in + WidgetRenderer(snapshot: entry.snapshot) + } + .configurationDisplayName("Stint") + .description("Time-tracking dashboard for stint.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +struct WidgetRenderer: View { + let snapshot: WidgetSnapshot + @Environment(\.widgetFamily) var family + + var body: some View { + switch snapshot { + case .runningTimer, .idleTimer, .unavailable: + RunningTimerView(snapshot: snapshot, size: family) + case .todayTotal: + TodayTotalView(snapshot: snapshot, size: family) + case .weekProject: + WeekProjectView(snapshot: snapshot, size: family) + } + } +} diff --git a/crates/stint-app/swift/StintWidget/Sources/StintWidget/StintWidgetBundle.swift b/crates/stint-app/swift/StintWidget/Sources/StintWidget/StintWidgetBundle.swift new file mode 100644 index 0000000..74dc0d4 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Sources/StintWidget/StintWidgetBundle.swift @@ -0,0 +1,9 @@ +import WidgetKit +import SwiftUI + +@main +struct StintWidgetBundle: WidgetBundle { + var body: some Widget { + RunningTimerWidget() + } +} diff --git a/crates/stint-app/swift/StintWidget/Sources/StintWidget/Stub.swift b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Stub.swift new file mode 100644 index 0000000..27fe1a9 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Stub.swift @@ -0,0 +1,5 @@ +import Foundation + +struct StintWidgetVersion { + static let current = "0.1.0" +} diff --git a/crates/stint-app/swift/StintWidget/Sources/StintWidget/Views/RunningTimerView.swift b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Views/RunningTimerView.swift new file mode 100644 index 0000000..253d9ed --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Views/RunningTimerView.swift @@ -0,0 +1,50 @@ +import SwiftUI +import WidgetKit + +struct RunningTimerView: View { + let snapshot: WidgetSnapshot + let size: WidgetFamily + + var body: some View { + switch snapshot { + case .runningTimer(let desc, let proj, let elapsed): + VStack(alignment: .leading, spacing: 4) { + Text(timeString(elapsed)) + .font(.system(size: size == .systemSmall ? 28 : 36, weight: .semibold, design: .rounded)) + .monospacedDigit() + Text(desc).font(.callout).lineLimit(size == .systemSmall ? 1 : 2) + if let p = proj { + Text(p).font(.caption).foregroundStyle(.secondary) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + case .idleTimer: + VStack(alignment: .leading, spacing: 4) { + Text("No active timer").font(.callout) + Text("Tap to open Stint").font(.caption).foregroundStyle(.secondary) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + case .unavailable: + VStack(alignment: .leading, spacing: 4) { + Text("Stint not running").font(.callout) + Text("Launch the app and re-try").font(.caption).foregroundStyle(.secondary) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + default: + EmptyView() + } + } + + private func timeString(_ secs: TimeInterval) -> String { + let total = Int(secs) + let h = total / 3600 + let m = (total % 3600) / 60 + return String(format: "%d:%02d", h, m) + } +} diff --git a/crates/stint-app/swift/StintWidget/Sources/StintWidget/Views/TodayTotalView.swift b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Views/TodayTotalView.swift new file mode 100644 index 0000000..faf5b4e --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Views/TodayTotalView.swift @@ -0,0 +1,39 @@ +import SwiftUI +import WidgetKit + +struct TodayTotalView: View { + let snapshot: WidgetSnapshot + let size: WidgetFamily + + var body: some View { + switch snapshot { + case .todayTotal(let total, let byProject): + VStack(alignment: .leading, spacing: 6) { + Text(timeString(total)) + .font(.system(size: 32, weight: .semibold, design: .rounded)) + .monospacedDigit() + Text("Today").font(.caption).foregroundStyle(.secondary) + if size == .systemMedium { + ForEach(byProject.prefix(3), id: \.name) { item in + HStack { + Text(item.name).font(.caption).lineLimit(1) + Spacer() + Text(timeString(item.seconds)).font(.caption).monospacedDigit() + } + } + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + default: + EmptyView() + } + } + + private func timeString(_ secs: TimeInterval) -> String { + let total = Int(secs) + let h = total / 3600 + let m = (total % 3600) / 60 + return "\(h)h \(m)m" + } +} diff --git a/crates/stint-app/swift/StintWidget/Sources/StintWidget/Views/WeekProjectView.swift b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Views/WeekProjectView.swift new file mode 100644 index 0000000..22b83c7 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Sources/StintWidget/Views/WeekProjectView.swift @@ -0,0 +1,51 @@ +import SwiftUI +import WidgetKit + +struct WeekProjectView: View { + let snapshot: WidgetSnapshot + let size: WidgetFamily + + var body: some View { + switch snapshot { + case .weekProject(let projectName, let total, let byDay): + VStack(alignment: .leading, spacing: 6) { + Text(projectName).font(.caption).foregroundStyle(.secondary).lineLimit(1) + Text(timeString(total)) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .monospacedDigit() + if size == .systemMedium { + BarChart(values: byDay) + .frame(height: 40) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + default: + EmptyView() + } + } + + private func timeString(_ secs: TimeInterval) -> String { + let total = Int(secs) + let h = total / 3600 + let m = (total % 3600) / 60 + return "\(h)h \(m)m" + } +} + +struct BarChart: View { + let values: [TimeInterval] + var body: some View { + GeometryReader { geo in + let maxVal = values.max() ?? 1 + HStack(alignment: .bottom, spacing: 2) { + ForEach(values.indices, id: \.self) { i in + Rectangle() + .fill(Color.accentColor) + .frame(width: (geo.size.width - CGFloat(values.count - 1) * 2) / CGFloat(values.count), + height: max(2, geo.size.height * CGFloat(values[i] / maxVal))) + } + } + } + } +} diff --git a/crates/stint-app/swift/StintWidget/Sources/StintWidget/WidgetConfigIntent.swift b/crates/stint-app/swift/StintWidget/Sources/StintWidget/WidgetConfigIntent.swift new file mode 100644 index 0000000..c7522c8 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Sources/StintWidget/WidgetConfigIntent.swift @@ -0,0 +1,57 @@ +import AppIntents +import WidgetKit + +enum WidgetKind: String, AppEnum, CaseIterable { + case runningTimer + case todayTotal + case weekProject + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Stint widget type" + + static var caseDisplayRepresentations: [WidgetKind : DisplayRepresentation] = [ + .runningTimer: "Running Timer", + .todayTotal: "Today Total", + .weekProject: "This-Week Project", + ] +} + +struct WidgetProjectEntity: AppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Project" + static var defaultQuery = WidgetProjectQuery() + + let id: String + let name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +struct WidgetProjectQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [WidgetProjectEntity] { + let all = try await fetchProjects() + return all.filter { identifiers.contains($0.id) } + } + func suggestedEntities() async throws -> [WidgetProjectEntity] { + try await fetchProjects() + } + + private func fetchProjects() async throws -> [WidgetProjectEntity] { + let port = try PortDiscovery.read() + let url = URL(string: "http://127.0.0.1:\(port)/v1/projects")! + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode([ProjectDTO].self, from: data) + .filter { !$0.archived } + .map { WidgetProjectEntity(id: $0.solidtime_id, name: $0.name) } + } +} + +struct WidgetConfigIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Configure Stint Widget" + + @Parameter(title: "Show", default: .runningTimer) + var kind: WidgetKind + + @Parameter(title: "Project") + var project: WidgetProjectEntity? +} diff --git a/crates/stint-app/swift/StintWidget/StintWidget.entitlements b/crates/stint-app/swift/StintWidget/StintWidget.entitlements new file mode 100644 index 0000000..84a9a28 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/StintWidget.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + + diff --git a/crates/stint-app/swift/StintWidget/Tests/StintWidgetTests/DTOCodingTests.swift b/crates/stint-app/swift/StintWidget/Tests/StintWidgetTests/DTOCodingTests.swift new file mode 100644 index 0000000..15de007 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Tests/StintWidgetTests/DTOCodingTests.swift @@ -0,0 +1,19 @@ +import Testing +import Foundation +@testable import StintWidget + +@Suite("DTO Coding") +struct DTOCodingTests { + @Test func entryDecodes() throws { + let json = #"{"local_uuid":"u1","solidtime_id":null,"description":"x","project_id":"p1","task_id":null,"billable":false,"start_at":"2026-05-27T10:00:00Z","end_at":null,"source":"test"}"# + let dto = try JSONDecoder().decode(EntryDTO.self, from: Data(json.utf8)) + #expect(dto.local_uuid == "u1") + #expect(dto.description == "x") + } + + @Test func projectDecodes() throws { + let json = #"{"solidtime_id":"p1","name":"Acme","color":null,"client_id":null,"archived":false}"# + let dto = try JSONDecoder().decode(ProjectDTO.self, from: Data(json.utf8)) + #expect(dto.name == "Acme") + } +} diff --git a/crates/stint-app/swift/StintWidget/Tests/StintWidgetTests/PortDiscoveryTests.swift b/crates/stint-app/swift/StintWidget/Tests/StintWidgetTests/PortDiscoveryTests.swift new file mode 100644 index 0000000..04f2208 --- /dev/null +++ b/crates/stint-app/swift/StintWidget/Tests/StintWidgetTests/PortDiscoveryTests.swift @@ -0,0 +1,30 @@ +import Testing +import Foundation +@testable import StintWidget + +@Suite("PortDiscovery") +struct PortDiscoveryTests { + @Test func readsValidPortFile() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("port-\(UUID()).txt") + try "49792\n".write(to: tmp, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tmp) } + let port = try PortDiscovery.read(from: tmp) + #expect(port == 49792) + } + + @Test func errorsWhenFileMissing() { + let nowhere = URL(fileURLWithPath: "/tmp/does-not-exist-\(UUID()).port") + #expect(throws: PortDiscoveryError.self) { + try PortDiscovery.read(from: nowhere) + } + } + + @Test func errorsOnGarbledFile() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("bad-\(UUID()).txt") + try "not-a-number".write(to: tmp, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tmp) } + #expect(throws: PortDiscoveryError.self) { + try PortDiscovery.read(from: tmp) + } + } +} diff --git a/crates/stint-app/tauri.conf.json b/crates/stint-app/tauri.conf.json index 9e3d02b..2fcc3b1 100644 --- a/crates/stint-app/tauri.conf.json +++ b/crates/stint-app/tauri.conf.json @@ -61,7 +61,10 @@ "providerShortName": null, "hardenedRuntime": true, "entitlements": "entitlements.plist", - "minimumSystemVersion": "13.0" + "minimumSystemVersion": "13.0", + "frameworks": [ + "Frameworks/StintIntents.framework" + ] } }, "plugins": { diff --git a/crates/stint-app/tests/api_port_file.rs b/crates/stint-app/tests/api_port_file.rs new file mode 100644 index 0000000..f277424 --- /dev/null +++ b/crates/stint-app/tests/api_port_file.rs @@ -0,0 +1,62 @@ +//! `api.port` file is written on bind, removed on drop. + +use std::fs; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use tempfile::TempDir; + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +struct EnvRestore { + previous: Option, +} + +impl Drop for EnvRestore { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var("STINT_DATA_DIR", value), + None => std::env::remove_var("STINT_DATA_DIR"), + } + } +} + +fn port_file_for(data_dir: &std::path::Path) -> PathBuf { + data_dir.join("api.port") +} + +#[test] +fn writes_port_file_on_bind() { + let tempdir = TempDir::new().unwrap(); + let _guard = env_lock().lock().unwrap(); + let _restore = EnvRestore { + previous: std::env::var_os("STINT_DATA_DIR"), + }; + std::env::set_var("STINT_DATA_DIR", tempdir.path()); + + let port = stint_app::http::write_port_file_for_test(49792).unwrap(); + assert_eq!(port, 49792); + let path = port_file_for(tempdir.path()); + assert!(path.exists(), "port file not at {}", path.display()); + let contents = fs::read_to_string(&path).unwrap(); + assert_eq!(contents, "49792\n"); +} + +#[test] +fn removes_port_file() { + let tempdir = TempDir::new().unwrap(); + let _guard = env_lock().lock().unwrap(); + let _restore = EnvRestore { + previous: std::env::var_os("STINT_DATA_DIR"), + }; + std::env::set_var("STINT_DATA_DIR", tempdir.path()); + + stint_app::http::write_port_file_for_test(49792).unwrap(); + let path = port_file_for(tempdir.path()); + assert!(path.exists()); + + stint_app::http::remove_port_file_for_test().unwrap(); + assert!(!path.exists()); +} diff --git a/crates/stint-app/tests/http_api.rs b/crates/stint-app/tests/http_api.rs index 346897d..9af39b0 100644 --- a/crates/stint-app/tests/http_api.rs +++ b/crates/stint-app/tests/http_api.rs @@ -128,6 +128,74 @@ async fn list_projects_and_tasks_return_empty_arrays_on_fresh_store() { assert_eq!(v, serde_json::json!([])); } +#[tokio::test] +async fn start_then_patch_round_trips_task_id_through_http() { + let ctx = common::make_app().await; + let app = stint_app::http::build_router(ctx.store.clone()); + + // POST /v1/start with task_id set should persist it and reflect it in + // the response view. + let resp = app + .clone() + .oneshot( + Request::post("/v1/start") + .header("content-type", "application/json") + .body(Body::from( + r#"{"description":"with task","project_id":"p-1","task_id":"t-1","source":"http"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + let id = v["local_uuid"].as_str().unwrap().to_string(); + assert_eq!(v["task_id"], "t-1"); + assert_eq!(v["project_id"], "p-1"); + + // GET /v1/current must echo the same task_id. + let resp = app + .clone() + .oneshot(Request::get("/v1/current").body(Body::empty()).unwrap()) + .await + .unwrap(); + let v = body_json(resp).await; + assert_eq!(v["task_id"], "t-1"); + + // PATCH with explicit `null` clears task_id (the 3-way Option> + // semantics of EntryPatch). + let resp = app + .clone() + .oneshot( + Request::patch(format!("/v1/entries/{id}")) + .header("content-type", "application/json") + .body(Body::from(r#"{"task_id":null}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert!( + v["task_id"].is_null(), + "task_id should be cleared, got {:?}", + v["task_id"] + ); + + // PATCH with a new value sets it back. + let resp = app + .oneshot( + Request::patch(format!("/v1/entries/{id}")) + .header("content-type", "application/json") + .body(Body::from(r#"{"task_id":"t-2"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let v = body_json(resp).await; + assert_eq!(v["task_id"], "t-2"); +} + #[tokio::test] async fn update_entry_via_http_returns_updated_view() { let ctx = common::make_app().await; diff --git a/crates/stint-app/tests/idle_commands.rs b/crates/stint-app/tests/idle_commands.rs new file mode 100644 index 0000000..79fdc75 --- /dev/null +++ b/crates/stint-app/tests/idle_commands.rs @@ -0,0 +1,50 @@ +//! Integration test for idle_discard / idle_split. Exercises the verb +//! layer the way the Tauri commands would — same store + arguments. + +mod common; + +use stint_core::store::entries::Entries; +use stint_core::store::running::RunningTimer; + +#[tokio::test] +async fn idle_discard_stops_entry_at_idle_started() { + let ctx = common::make_app().await; + + let start_at = "2026-05-27T10:00:00Z"; + let view = stint_core::verbs::start( + &ctx.store, + stint_core::verbs::StartParams { + description: "deep work".into(), + project_id: None, + task_id: None, + billable: false, + start_at: Some(start_at.into()), + source: "test".into(), + }, + ) + .await + .unwrap(); + + let idle_started = "2026-05-27T10:18:00Z"; + + stint_app::commands::idle::discard_impl(&ctx.store, idle_started) + .await + .unwrap(); + + let row = Entries::new((*ctx.store).clone()) + .get(&view.local_uuid) + .await + .unwrap() + .unwrap(); + assert_eq!(row.end_at.as_deref(), Some(idle_started)); + + let running = RunningTimer::new((*ctx.store).clone()).get().await.unwrap(); + assert!(running.is_none()); +} + +#[tokio::test] +async fn idle_discard_errors_when_no_running_timer() { + let ctx = common::make_app().await; + let result = stint_app::commands::idle::discard_impl(&ctx.store, "2026-05-27T10:00:00Z").await; + assert!(matches!(result, Err(stint_core::Error::Invariant(_)))); +} diff --git a/crates/stint-app/tests/idle_detector.rs b/crates/stint-app/tests/idle_detector.rs new file mode 100644 index 0000000..12e126b --- /dev/null +++ b/crates/stint-app/tests/idle_detector.rs @@ -0,0 +1,56 @@ +//! Idle detector state machine (no actual CGEvent polling — that's tested +//! end-to-end via manual smoke). + +use stint_app::idle_detector::{advance, IdleState}; + +#[test] +fn no_event_when_below_threshold() { + let mut state = IdleState::default(); + let evt = advance( + &mut state, /*idle_secs*/ 30.0, /*now*/ 1000, /*threshold*/ 600, + /*timer_running*/ true, + ); + assert!(evt.is_none()); + assert!(state.pending_idle.is_none()); +} + +#[test] +fn arms_pending_idle_when_threshold_reached() { + let mut state = IdleState::default(); + // Idle for 720s when polled at t=1000 means idleness began at t=280 + let evt = advance(&mut state, 720.0, 1000, 600, true); + assert!(evt.is_none()); + assert_eq!(state.pending_idle, Some(280)); +} + +#[test] +fn emits_event_when_activity_resumes() { + let mut state = IdleState { + pending_idle: Some(280), + }; + let evt = advance( + &mut state, /*idle_secs*/ 3.0, /*now*/ 1100, 600, true, + ); + assert!(evt.is_some()); + let evt = evt.unwrap(); + assert_eq!(evt.idle_started, 280); + assert_eq!(evt.idle_secs, 820); // now - pending_idle + assert!(state.pending_idle.is_none()); +} + +#[test] +fn no_event_when_timer_not_running() { + let mut state = IdleState::default(); + let evt = advance(&mut state, 720.0, 1000, 600, false); + assert!(evt.is_none()); +} + +#[test] +fn drops_pending_when_timer_stops() { + let mut state = IdleState { + pending_idle: Some(280), + }; + let evt = advance(&mut state, 3.0, 1100, 600, false); + assert!(evt.is_none()); + assert!(state.pending_idle.is_none()); +} diff --git a/crates/stint-app/tests/projects_commands.rs b/crates/stint-app/tests/projects_commands.rs index 73061b9..3ff21bb 100644 --- a/crates/stint-app/tests/projects_commands.rs +++ b/crates/stint-app/tests/projects_commands.rs @@ -2,10 +2,12 @@ mod common; -use stint_app::commands::projects::{list_organizations, list_projects, refresh_projects}; +use stint_app::commands::projects::{ + list_organizations, list_projects, list_tasks, refresh_projects, +}; use stint_core::config::secrets::Secrets; use stint_core::config::Settings; -use stint_core::store::reference::{ProjectRow, Reference}; +use stint_core::store::reference::{ProjectRow, Reference, TaskRow}; use tauri::Manager; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -58,6 +60,69 @@ async fn list_projects_returns_seeded_rows() { assert_eq!(rows[0].name, "Tet"); } +#[tokio::test(flavor = "multi_thread")] +async fn list_tasks_returns_empty_when_no_project_filter_and_no_tasks() { + let ctx = common::make_app().await; + let handle = ctx.handle(); + let rows = list_tasks(handle.state(), None).await.unwrap(); + assert!(rows.is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_tasks_filters_by_project_id() { + let ctx = common::make_app().await; + let reference = Reference::new((*ctx.store).clone()); + reference + .upsert_projects(&[ + ProjectRow { + id: "p-1".into(), + name: "Alpha".into(), + color: None, + client_id: None, + client_name: None, + archived: 0, + billable_default: 0, + }, + ProjectRow { + id: "p-2".into(), + name: "Beta".into(), + color: None, + client_id: None, + client_name: None, + archived: 0, + billable_default: 0, + }, + ]) + .await + .unwrap(); + reference + .upsert_tasks(&[ + TaskRow { + id: "t-1".into(), + project_id: "p-1".into(), + name: "Implement".into(), + done: 0, + }, + TaskRow { + id: "t-2".into(), + project_id: "p-2".into(), + name: "Other".into(), + done: 0, + }, + ]) + .await + .unwrap(); + + let handle = ctx.handle(); + let rows = list_tasks(handle.state(), Some("p-1".into())) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].solidtime_id, "t-1"); + assert_eq!(rows[0].project_id, "p-1"); + assert_eq!(rows[0].name, "Implement"); +} + #[tokio::test(flavor = "multi_thread")] async fn list_organizations_fetches_memberships_from_solidtime() { let ctx = common::make_app().await; diff --git a/crates/stint-app/tests/timer_commands.rs b/crates/stint-app/tests/timer_commands.rs index 447b49e..5379dde 100644 --- a/crates/stint-app/tests/timer_commands.rs +++ b/crates/stint-app/tests/timer_commands.rs @@ -10,7 +10,8 @@ mod common; use stint_app::commands::timer::{ delete_entry, get_running_timer, restart_entry, set_entry_billable, set_entry_project, - start_timer, stop_timer, update_description, update_entry_times, StartTimerArgs, + set_entry_task, start_timer, stop_timer, update_description, update_entry_times, + StartTimerArgs, }; use stint_core::store::entries::Entries; use stint_core::store::queue::Queue; @@ -375,6 +376,83 @@ async fn set_entry_project_round_trips() { assert_eq!(row.project_id, None); } +#[tokio::test(flavor = "multi_thread")] +async fn start_timer_persists_task_id_when_provided() { + let ctx = common::make_app().await; + let handle = ctx.handle(); + + let view = start_timer( + handle.clone(), + handle.state(), + StartTimerArgs { + description: "with task".into(), + project_id: Some("p-1".into()), + task_id: Some("t-1".into()), + billable: false, + start_at: None, + }, + ) + .await + .expect("start_timer succeeds"); + assert_eq!(view.task_id.as_deref(), Some("t-1")); + + let row = Entries::new((*ctx.store).clone()) + .get(&view.local_uuid) + .await + .unwrap() + .unwrap(); + assert_eq!(row.task_id.as_deref(), Some("t-1")); + assert_eq!(row.project_id.as_deref(), Some("p-1")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_entry_task_round_trips() { + let ctx = common::make_app().await; + let handle = ctx.handle(); + + let view = start_timer( + handle.clone(), + handle.state(), + StartTimerArgs { + description: "x".into(), + project_id: Some("p-1".into()), + task_id: None, + billable: false, + start_at: None, + }, + ) + .await + .unwrap(); + let id = view.local_uuid.clone(); + + set_entry_task( + handle.clone(), + handle.state(), + id.clone(), + Some("t-9".into()), + ) + .await + .expect("set task succeeds"); + + let row = Entries::new((*ctx.store).clone()) + .get(&id) + .await + .unwrap() + .unwrap(); + assert_eq!(row.task_id.as_deref(), Some("t-9")); + + // Clearing the task also round-trips. + set_entry_task(handle.clone(), handle.state(), id.clone(), None) + .await + .unwrap(); + let row = Entries::new((*ctx.store).clone()) + .get(&id) + .await + .unwrap() + .unwrap(); + assert_eq!(row.task_id, None); +} + #[tokio::test(flavor = "multi_thread")] async fn set_entry_billable_round_trips() { let ctx = common::make_app().await; diff --git a/crates/stint-cli/skills/stint/SKILL.md b/crates/stint-cli/skills/stint/SKILL.md index c8176ac..51cb60f 100644 --- a/crates/stint-cli/skills/stint/SKILL.md +++ b/crates/stint-cli/skills/stint/SKILL.md @@ -34,6 +34,29 @@ You have up to three ways to talk to stint. Use the highest one that works. **Pick a surface and stick with it within a single user request** to avoid mixing read/write paths. +### Bonus surfaces (Phases 6b + 6c) + +- **stint:// URL routes** (live): + - `stint://entry/` → opens Today, scrolls to the matching row, briefly highlights it. + - `stint://project/` → opens Today view filtered to the project. + - `stint://task/` → resolves task → parent project, filters by both. + - Existing: `stint://start?description=…&project=…`, `stint://stop`, `stint://current`. + +- **Core Spotlight** (live): entries, projects, and tasks are indexed; tapping a Spotlight result routes through the appropriate `stint://` URL above. The currently-running entry also appears as an NSUserActivity "Tracking: …" tile. + +- **macOS Focus filter integration** (foundation shipped, end-user UI activation deferred): + - `verbs::start` reads `focus.default_project` from settings and applies it when no `project_id` is passed. The Rust side is in place; the System Settings → Focus → Stint surface that *writes* the setting isn't yet active — see spec §1.5. + +- **App Intents (Siri / Shortcuts.app)** — **NOT YET LIVE**. The Swift code is shipped but Apple's intent indexer doesn't discover the types from our framework-embedded package. A follow-up using Xcode's App Intents Extension template will enable Siri voice and Shortcuts.app discovery. Don't tell users to "say 'Hey Siri, start tracking in Stint'" yet. + +- **Raycast extension** (Phase 6c live): five commands — Start Timer, Stop, Current, Recent Entries, Switch Project. Install via Import Extension from `raycast-stint/` until the Raycast Store listing lands. + +- **Alfred workflow** (Phase 6c live): keywords `s ` (start), `sstop`, `scur`, `srec`. Install via the .alfredworkflow bundle from GitHub Releases. + +- **WidgetKit widget** (Phase 6c live): per-instance configurable. Three kinds (Running Timer, Today Total, This-Week Project) × two sizes (small, medium). Auto-enables the loopback HTTP API on first widget install. + +- **Idle detection** (Phase 6c live): When a timer is running and you've been idle ≥10 minutes (configurable in Settings), a banner offers to Keep, Discard, or Discard+restart. Threshold is `idle.threshold_secs` (default 600). + ## When to use this skill Triggers (not exhaustive): @@ -55,6 +78,8 @@ Triggers (not exhaustive): 2. If running, ask the user whether to stop the current one first. 3. `start { description, project_id?, task_id?, billable? }`. The `source` field is auto-set to `"mcp"` (or `"cli"`/`"http"`). +CLI equivalent: `stint start "writing tests" --project --task `. Tasks scope to projects — always pass `--project` together with `--task`; passing only `--task` is accepted today but will be rejected by Solidtime sync if the task doesn't belong to a project the entry references. + ### Switch projects 1. `current` → returns the running entry. 2. `stop`. @@ -105,6 +130,8 @@ Users say "the auth project", "feature X", "PR review" — they don't say `01HPY Tasks: `list_tasks { project_id }` to scope the lookup. Most users don't reference tasks by name often. +CLI equivalent for tasks: `stint edit --task ` to set, `stint edit --clear-task` to clear. The two flags are mutually exclusive — clap rejects passing both. Same shape as the `--project` / `--clear-project` pair. + If a `start` returns "project_id not found", the project may be archived or not yet pulled from Solidtime — suggest `stint pull` to refresh reference data. ## Time math reference diff --git a/crates/stint-cli/src/cmd/projects.rs b/crates/stint-cli/src/cmd/projects.rs index fdec5ff..78f67de 100644 --- a/crates/stint-cli/src/cmd/projects.rs +++ b/crates/stint-cli/src/cmd/projects.rs @@ -1,5 +1,5 @@ use anyhow::{anyhow, Result}; -use clap::Subcommand; +use clap::{Args, Subcommand}; use stint_core::config::{secrets::Secrets, Settings}; use stint_core::solidtime::auth::build_token_provider; use stint_core::solidtime::SolidtimeClient; @@ -13,12 +13,20 @@ use super::open_store; pub enum ProjectsCmd { /// List cached projects (run `projects refresh` first to pull). List, + /// List tasks for a project (by Solidtime project ID). + ListTasks(ListTasksArgs), /// Pull projects/tasks/tags from Solidtime. Refresh, /// Print the raw Solidtime `/projects` response. Diagnostic only. Raw, } +#[derive(Args)] +pub struct ListTasksArgs { + /// Solidtime project ID to filter tasks by. + pub project_id: String, +} + pub async fn run(p: ProjectsCmd, json: bool) -> Result<()> { let store = open_store().await?; match p { @@ -38,6 +46,19 @@ pub async fn run(p: ProjectsCmd, json: bool) -> Result<()> { } Ok(()) } + ProjectsCmd::ListTasks(args) => { + let tasks = verbs::list_tasks(&store, Some(args.project_id)).await?; + crate::render::render(&tasks, json, |tasks| { + if tasks.is_empty() { + println!("(no tasks)"); + } else { + for t in tasks { + println!(" {} {}", t.solidtime_id, t.name); + } + } + }); + Ok(()) + } ProjectsCmd::Refresh => { let client = build_client(&store).await?; refresh_reference_data(&store, &client).await?; diff --git a/crates/stint-cli/tests/projects_list_tasks.rs b/crates/stint-cli/tests/projects_list_tasks.rs new file mode 100644 index 0000000..4b1a7c7 --- /dev/null +++ b/crates/stint-cli/tests/projects_list_tasks.rs @@ -0,0 +1,22 @@ +//! `stint projects list-tasks ` returns tasks for a project. + +use assert_cmd::Command; +use serde_json::Value; +use tempfile::TempDir; + +#[test] +fn list_tasks_empty_when_no_data() { + let tempdir = TempDir::new().unwrap(); + let output = Command::cargo_bin("stint") + .unwrap() + .env("STINT_DATA_DIR", tempdir.path()) + .args(["--json", "projects", "list-tasks", "proj-abc"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let json: Value = serde_json::from_slice(&output).unwrap(); + assert!(json.is_array()); + assert_eq!(json.as_array().unwrap().len(), 0); +} diff --git a/crates/stint-core/Cargo.toml b/crates/stint-core/Cargo.toml index d540976..d53486c 100644 --- a/crates/stint-core/Cargo.toml +++ b/crates/stint-core/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true async-trait.workspace = true base64 = "0.22" chrono.workspace = true +libc = "0.2" dirs.workspace = true keyring.workspace = true oauth2.workspace = true diff --git a/crates/stint-core/include/stint_core.h b/crates/stint-core/include/stint_core.h new file mode 100644 index 0000000..af6909b --- /dev/null +++ b/crates/stint-core/include/stint_core.h @@ -0,0 +1,81 @@ +/* + * stint_core.h + * + * C ABI declarations for the StintIntents Swift framework. Mirrors the + * extern "C" surface defined in `crates/stint-core/src/ffi.rs`. + * + * Every verb fn returns 0 (success — see `out_json` for the JSON envelope) + * or -2 on misuse (null pointer where one was required). Envelopes are + * either `{"ok": }` or `{"err": {"code": , "message": ""}}`. + * Error codes: 1=Invariant, 2=NotFound, 4=Serialization, 99=Internal, + * -1=Panic (caught across the C ABI by `catch_unwind`). + * + * Memory ownership: every non-NULL `*out_json` was malloc'd by Rust via + * `CString::into_raw` and MUST be freed by the caller via + * `stint_free_string`. Passing NULL to `stint_free_string` is safe. + */ + +#ifndef STINT_CORE_H +#define STINT_CORE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- string lifecycle ---- */ +void stint_free_string(char *ptr); + +/* ---- verbs ---- */ +int32_t stint_verb_start(const char *params_json, char **out_json); +int32_t stint_verb_stop(char **out_json); +int32_t stint_verb_current(char **out_json); +int32_t stint_verb_list_entries(const char *filter_json, char **out_json); +int32_t stint_verb_list_projects(char **out_json); +int32_t stint_verb_list_tasks(const char *params_json, char **out_json); +int32_t stint_verb_update_entry(const char *params_json, char **out_json); +int32_t stint_verb_delete_entry(const char *params_json, char **out_json); + +/* ---- settings (opaque key/value strings) ---- */ +int32_t stint_settings_set(const char *key, const char *value); +int32_t stint_settings_get(const char *key, char **out_json); +int32_t stint_settings_clear(const char *key); + +/* ---- log forwarder (Swift → tracing) ---- */ +void stint_log_warn(const char *msg); + +/* ---- focus id (resolved via dlsym to Swift's stint_current_focus_id_swift) ---- */ +/* `*out_json` is NULL when no focus is active (or framework not loaded). */ +int32_t stint_current_focus_id(char **out_json); + +/* + * Swift exports the following symbols; Rust looks them up via dlsym + * (RTLD_DEFAULT walks the global symbol table when both Rust and Swift + * are loaded in the same process). They are listed here for reference. + */ +/* + * int32_t stint_intents_init(void); + * Called once from Tauri's setup() hook. Triggers the framework load + * (first FFI symbol reference), kicks off Spotlight bulk refresh, and + * activates NSUserActivity for any currently running entry. + * + * void swift_indexer_notify(int32_t kind, const char *payload_json); + * IndexerKind values (stable contract): + * 1 = EntryStarted payload = EntryView JSON + * 2 = EntryStopped payload = EntryView JSON + * 3 = EntryUpdated payload = EntryView JSON + * 4 = EntryDeleted payload = {"local_uuid": "..."} + * 5 = ProjectsReplaced payload = [ProjectView, ...] + * 6 = TasksReplaced payload = [TaskView, ...] + * + * int32_t stint_current_focus_id_swift(char **out_json); + * Backs `stint_current_focus_id`. Returns 0 with *out_json malloc'd + * (or NULL if no focus is active). + */ + +#ifdef __cplusplus +} +#endif + +#endif /* STINT_CORE_H */ diff --git a/crates/stint-core/src/ffi.rs b/crates/stint-core/src/ffi.rs new file mode 100644 index 0000000..b195273 --- /dev/null +++ b/crates/stint-core/src/ffi.rs @@ -0,0 +1,528 @@ +//! C ABI surface for Swift consumers (the StintIntents framework). +//! +//! Every public `extern "C"` function writes a JSON envelope into `out_json`: +//! +//! ```text +//! { "ok": } +//! { "err": { "code": , "message": "" } } +//! ``` +//! +//! Error codes are a stable public contract — do not renumber. See the spec +//! at `docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md#71-envelope-contract`. +//! +//! Memory ownership: every `*out_json` is malloc'd by Rust via `CString::into_raw`. +//! Callers must free it via [`stint_free_string`]. Passing NULL to +//! `stint_free_string` is safe and is a no-op. +//! +//! Panic safety: each FFI fn body runs inside `catch_unwind`. A caught panic +//! becomes a `-1` envelope rather than undefined behavior across the C ABI. + +use crate::config::Settings; +use crate::store::Store; +use crate::{paths, verbs, Error}; +use serde::{Deserialize, Serialize}; +use std::ffi::{c_char, CStr, CString}; +use std::panic; +use std::ptr; +use std::sync::OnceLock; +use tokio::runtime::Runtime; + +/// Stable error-code contract — see module docs. +#[repr(i32)] +#[derive(Debug, Clone, Copy)] +enum Code { + Invariant = 1, + NotFound = 2, + // 3 = Conflict — reserved for future use (no current Error variant maps to it) + Serialization = 4, + Internal = 99, + Panic = -1, +} + +fn code_for(err: &Error) -> i32 { + match err { + Error::Invariant(_) => Code::Invariant as i32, + Error::NotFound(_) => Code::NotFound as i32, + Error::Serde(_) => Code::Serialization as i32, + _ => Code::Internal as i32, + } +} + +/// Build a `{ok:T} | {err:{code,message}}` envelope JSON and write it to +/// `*out_json` as a heap-allocated CString. The caller (Swift) is +/// responsible for freeing the string via [`stint_free_string`]. +fn write_envelope(out_json: *mut *mut c_char, result: Result) { + if out_json.is_null() { + return; + } + let body = match result { + Ok(t) => serde_json::json!({ "ok": t }), + Err(e) => serde_json::json!({ + "err": { "code": code_for(&e), "message": e.to_string() } + }), + }; + let s = body.to_string(); + let c = match CString::new(s) { + Ok(c) => c, + Err(_) => CString::new(r#"{"err":{"code":99,"message":"cstring contained internal NUL"}}"#) + .unwrap(), + }; + unsafe { *out_json = c.into_raw() }; +} + +/// Wrap an FFI body in `catch_unwind`. On panic, write a Panic envelope. +fn ffi_body(out_json: *mut *mut c_char, f: F) +where + F: FnOnce() -> Result + std::panic::UnwindSafe, + T: Serialize, +{ + let result = panic::catch_unwind(f); + match result { + Ok(r) => write_envelope(out_json, r), + Err(p) => { + let msg = downcast_panic(p); + let body = serde_json::json!({ + "err": { "code": Code::Panic as i32, "message": msg } + }); + let c = CString::new(body.to_string()).unwrap_or_else(|_| { + CString::new(r#"{"err":{"code":-1,"message":"panic"}}"#).unwrap() + }); + if !out_json.is_null() { + unsafe { *out_json = c.into_raw() }; + } + } + } +} + +fn downcast_panic(p: Box) -> String { + if let Some(s) = p.downcast_ref::<&'static str>() { + return (*s).to_owned(); + } + if let Some(s) = p.downcast_ref::() { + return s.clone(); + } + "rust panic (no message)".into() +} + +/// Free a CString previously returned via `*out_json`. Safe to call with NULL. +/// +/// # Safety +/// +/// `ptr` must either be NULL or have been produced by one of this module's +/// FFI functions via `CString::into_raw`. Calling with any other pointer is +/// undefined behavior. +#[no_mangle] +pub unsafe extern "C" fn stint_free_string(ptr: *mut c_char) { + if ptr.is_null() { + return; + } + let _ = CString::from_raw(ptr); +} + +// ---- test-only re-exports --------------------------------------------- + +/// Test-only helper: exposes the internal envelope writer so unit tests can +/// exercise `write_envelope` without a verb context. +#[doc(hidden)] +pub fn write_envelope_for_test(out_json: *mut *mut c_char, result: Result) { + write_envelope(out_json, result); +} + +/// Test-only helper: forces the `ffi_body` panic path so the `catch_unwind` +/// branch is exercised end-to-end. +#[doc(hidden)] +pub fn panic_for_test(out_json: *mut *mut c_char) { + ffi_body::<_, ()>(out_json, || panic!("test panic")); +} + +// ---- shared runtime + store ------------------------------------------ + +/// Lazy multi-threaded Tokio runtime used to `block_on` async verbs from +/// the synchronous FFI surface. One process-wide runtime; Swift callers +/// never see tokio. +fn runtime() -> &'static Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("ffi: failed to build tokio runtime") + }) +} + +/// Open the user-default `Store` for the current process and cache it. +/// +/// The cache key is the DB path resolved via `paths::database_path()`. If +/// `STINT_DATA_DIR` changes between calls (tests do this), the cache +/// re-opens against the new path. Production opens once. +fn store() -> Result { + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Mutex; + static CACHE: OnceLock>> = OnceLock::new(); + + let path = paths::database_path()?; + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + { + let guard = cache.lock().unwrap(); + if let Some(s) = guard.get(&path) { + return Ok(s.clone()); + } + } + let s = runtime().block_on(Store::connect(&path))?; + cache.lock().unwrap().insert(path, s.clone()); + Ok(s) +} + +/// Parse a JSON-encoded `*const c_char` into a Deserialize. NULL maps to +/// `Error::Invariant` so the caller sees an `err.code = 1` envelope. +unsafe fn parse_params<'a, T: Deserialize<'a>>(ptr: *const c_char) -> Result { + if ptr.is_null() { + return Err(Error::Invariant("null params pointer".into())); + } + let cstr = unsafe { CStr::from_ptr(ptr) }; + let s = cstr + .to_str() + .map_err(|e| Error::Invariant(format!("non-utf8 params: {e}")))?; + serde_json::from_str(s).map_err(Error::Serde) +} + +// ---- verbs ----------------------------------------------------------- + +/// Start a new running entry. JSON params match `verbs::StartParams`. +/// +/// # Safety +/// `params_json` is a NUL-terminated C string. `out_json` must point at a +/// valid `*mut c_char` slot to receive the envelope (must be freed by the +/// caller via [`stint_free_string`]). +#[no_mangle] +pub unsafe extern "C" fn stint_verb_start( + params_json: *const c_char, + out_json: *mut *mut c_char, +) -> i32 { + ffi_body(out_json, || { + let params: verbs::StartParams = unsafe { parse_params(params_json) }?; + let store = store()?; + runtime().block_on(verbs::start(&store, params)) + }); + 0 +} + +/// Stop the running entry. No params. +/// +/// # Safety +/// `out_json` must point at a valid `*mut c_char` slot. +#[no_mangle] +pub unsafe extern "C" fn stint_verb_stop(out_json: *mut *mut c_char) -> i32 { + ffi_body(out_json, || { + let store = store()?; + runtime().block_on(verbs::stop(&store)) + }); + 0 +} + +/// Return the currently-running entry as `Option` (null if idle). +/// +/// # Safety +/// `out_json` must point at a valid `*mut c_char` slot. +#[no_mangle] +pub unsafe extern "C" fn stint_verb_current(out_json: *mut *mut c_char) -> i32 { + ffi_body(out_json, || { + let store = store()?; + runtime().block_on(verbs::current(&store)) + }); + 0 +} + +/// List entries matching the given `EntryFilter` (JSON-encoded). +/// +/// # Safety +/// `filter_json` is a NUL-terminated JSON string (use `"{}"` for no filter). +#[no_mangle] +pub unsafe extern "C" fn stint_verb_list_entries( + filter_json: *const c_char, + out_json: *mut *mut c_char, +) -> i32 { + ffi_body(out_json, || { + let filter: verbs::EntryFilter = unsafe { parse_params(filter_json) }?; + let store = store()?; + runtime().block_on(verbs::list_entries(&store, filter)) + }); + 0 +} + +/// List all known projects. +/// +/// # Safety +/// `out_json` must point at a valid `*mut c_char` slot. +#[no_mangle] +pub unsafe extern "C" fn stint_verb_list_projects(out_json: *mut *mut c_char) -> i32 { + ffi_body(out_json, || { + let store = store()?; + runtime().block_on(verbs::list_projects(&store)) + }); + 0 +} + +/// JSON shape for the `stint_verb_list_tasks` param: `{"project_id": "..."}` or `{}`. +#[derive(Deserialize)] +struct ListTasksParams { + #[serde(default)] + project_id: Option, +} + +/// List tasks for the given project, or all tasks if `project_id` is omitted. +/// +/// # Safety +/// `params_json` is a NUL-terminated JSON string. +#[no_mangle] +pub unsafe extern "C" fn stint_verb_list_tasks( + params_json: *const c_char, + out_json: *mut *mut c_char, +) -> i32 { + ffi_body(out_json, || { + let p: ListTasksParams = unsafe { parse_params(params_json) }?; + let store = store()?; + runtime().block_on(verbs::list_tasks(&store, p.project_id)) + }); + 0 +} + +/// JSON shape: `{"local_uuid": "...", "patch": }`. +#[derive(Deserialize)] +struct UpdateEntryParams { + local_uuid: String, + patch: verbs::EntryPatch, +} + +/// Apply an `EntryPatch` to the entry identified by `local_uuid`. +/// +/// # Safety +/// `params_json` is a NUL-terminated JSON string. +#[no_mangle] +pub unsafe extern "C" fn stint_verb_update_entry( + params_json: *const c_char, + out_json: *mut *mut c_char, +) -> i32 { + ffi_body(out_json, || { + let p: UpdateEntryParams = unsafe { parse_params(params_json) }?; + let store = store()?; + runtime().block_on(verbs::update_entry(&store, &p.local_uuid, p.patch)) + }); + 0 +} + +/// JSON shape: `{"local_uuid": "..."}`. +#[derive(Deserialize)] +struct DeleteEntryParams { + local_uuid: String, +} + +/// Delete the entry identified by `local_uuid`. Envelope `ok` is `{}` on success. +/// +/// # Safety +/// `params_json` is a NUL-terminated JSON string. +#[no_mangle] +pub unsafe extern "C" fn stint_verb_delete_entry( + params_json: *const c_char, + out_json: *mut *mut c_char, +) -> i32 { + ffi_body(out_json, || { + let p: DeleteEntryParams = unsafe { parse_params(params_json) }?; + let store = store()?; + runtime().block_on(verbs::delete_entry(&store, &p.local_uuid))?; + Ok::<_, Error>(serde_json::json!({})) + }); + 0 +} + +// ---- settings ------------------------------------------------------- + +/// Opaque key/value setter. Returns 0 on success, non-zero on failure +/// (most commonly a closed DB or malformed UTF-8). +/// +/// # Safety +/// Both pointers must reference NUL-terminated UTF-8 strings. +#[no_mangle] +pub unsafe extern "C" fn stint_settings_set(key: *const c_char, value: *const c_char) -> i32 { + if key.is_null() || value.is_null() { + return -2; + } + let result = panic::catch_unwind(|| -> Result<(), Error> { + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|e| Error::Invariant(format!("non-utf8 key: {e}")))?; + let value = unsafe { CStr::from_ptr(value) } + .to_str() + .map_err(|e| Error::Invariant(format!("non-utf8 value: {e}")))?; + let store = store()?; + let settings = Settings::new(store); + runtime().block_on(settings.set(key, value)) + }); + match result { + Ok(Ok(())) => 0, + _ => 1, + } +} + +/// Opaque value getter. Returns 0 with `*out_json` set to a malloc'd +/// CString on success, or to NULL if the key is absent. Returns non-zero +/// on internal failure. +/// +/// # Safety +/// `key` must be NUL-terminated UTF-8. `out_json` must be a valid pointer. +/// Caller must free `*out_json` via [`stint_free_string`]. +#[no_mangle] +pub unsafe extern "C" fn stint_settings_get(key: *const c_char, out_json: *mut *mut c_char) -> i32 { + if key.is_null() || out_json.is_null() { + return -2; + } + unsafe { *out_json = ptr::null_mut() }; + let result = panic::catch_unwind(|| -> Result, Error> { + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|e| Error::Invariant(format!("non-utf8 key: {e}")))?; + let store = store()?; + let settings = Settings::new(store); + runtime().block_on(settings.get(key)) + }); + match result { + Ok(Ok(Some(v))) => { + if let Ok(c) = CString::new(v) { + unsafe { *out_json = c.into_raw() }; + } + 0 + } + Ok(Ok(None)) => 0, + _ => 1, + } +} + +/// Delete a settings key. Idempotent — returns 0 even if the key didn't exist. +/// +/// # Safety +/// `key` must be NUL-terminated UTF-8. +#[no_mangle] +pub unsafe extern "C" fn stint_settings_clear(key: *const c_char) -> i32 { + if key.is_null() { + return -2; + } + let result = panic::catch_unwind(|| -> Result<(), Error> { + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|e| Error::Invariant(format!("non-utf8 key: {e}")))?; + let store = store()?; + let settings = Settings::new(store); + runtime().block_on(settings.delete(key)) + }); + match result { + Ok(Ok(())) => 0, + _ => 1, + } +} + +// ---- log forwarder -------------------------------------------------- + +/// Forward a UTF-8 warning message into stint's tracing subscriber under +/// the `stint_intents` target. Best-effort — silently drops malformed +/// strings. +/// +/// # Safety +/// `msg` must be NUL-terminated UTF-8, or NULL (no-op). +#[no_mangle] +pub unsafe extern "C" fn stint_log_warn(msg: *const c_char) { + if msg.is_null() { + return; + } + if let Ok(s) = unsafe { CStr::from_ptr(msg) }.to_str() { + tracing::warn!(target: "stint_intents", "{}", s); + } +} + +// ---- focus id (dlsym'd from Swift; null when framework absent) ------ + +type FocusIdFn = unsafe extern "C" fn(*mut *mut c_char) -> i32; +static FOCUS_ID_SYMBOL: OnceLock> = OnceLock::new(); + +unsafe fn lookup_focus_id() -> Option { + *FOCUS_ID_SYMBOL.get_or_init(|| { + // Use RTLD_DEFAULT (null) so dlsym walks the global symbol table — + // it will find swift_indexer_notify / stint_current_focus_id_swift + // when the Swift framework is loaded into the same process, and + // return null otherwise (CLI binaries, headless tests). + let name = std::ffi::CString::new("stint_current_focus_id_swift").unwrap(); + let sym = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr()) }; + if sym.is_null() { + None + } else { + Some(unsafe { std::mem::transmute::<*mut libc::c_void, FocusIdFn>(sym) }) + } + }) +} + +/// Return the currently-active macOS Focus identifier via Swift bridge. +/// `*out_json` is set to a malloc'd CString on success, or NULL if no focus +/// is active (or the Swift framework isn't loaded). Returns 0 in both cases. +/// +/// # Safety +/// `out_json` must be a valid `*mut c_char` slot. Caller frees via +/// [`stint_free_string`]. +#[no_mangle] +pub unsafe extern "C" fn stint_current_focus_id(out_json: *mut *mut c_char) -> i32 { + if out_json.is_null() { + return -2; + } + unsafe { *out_json = ptr::null_mut() }; + if let Some(f) = unsafe { lookup_focus_id() } { + unsafe { f(out_json) } + } else { + 0 + } +} + +// ---- indexer notify (Rust → Swift via dlsym) ------------------------ + +/// Categorizes the payload Rust hands to the Swift indexer. Numeric values +/// are a stable contract — see the spec's "Indexer lifecycle" section. +#[repr(i32)] +#[derive(Debug, Clone, Copy)] +pub enum IndexerKind { + EntryStarted = 1, + EntryStopped = 2, + EntryUpdated = 3, + EntryDeleted = 4, + ProjectsReplaced = 5, + TasksReplaced = 6, +} + +type IndexerNotifyFn = unsafe extern "C" fn(i32, *const c_char); +static INDEXER_NOTIFY_SYMBOL: OnceLock> = OnceLock::new(); + +unsafe fn lookup_indexer_notify() -> Option { + *INDEXER_NOTIFY_SYMBOL.get_or_init(|| { + let name = std::ffi::CString::new("swift_indexer_notify").unwrap(); + let sym = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr()) }; + if sym.is_null() { + None + } else { + Some(unsafe { std::mem::transmute::<*mut libc::c_void, IndexerNotifyFn>(sym) }) + } + }) +} + +/// Notify the Swift Spotlight indexer about a mutation. No-op when the +/// Swift framework isn't loaded (CLI builds, headless tests). +/// +/// Call this from verb call sites and the pull worker after a successful +/// write. The payload is verb-specific JSON; see Swift's `Spotlight/SpotlightIndexer.swift` +/// `delta(kind:payload:)` for the decoders. +pub fn notify_indexer(kind: IndexerKind, payload_json: &str) { + let Some(f) = (unsafe { lookup_indexer_notify() }) else { + return; + }; + let Ok(c) = CString::new(payload_json) else { + return; + }; + unsafe { f(kind as i32, c.as_ptr()) }; +} diff --git a/crates/stint-core/src/focus.rs b/crates/stint-core/src/focus.rs new file mode 100644 index 0000000..c7c11cf --- /dev/null +++ b/crates/stint-core/src/focus.rs @@ -0,0 +1,35 @@ +//! Look up the currently active macOS Focus identifier. +//! +//! Production path: dlsym into Swift's `stint_current_focus_id_swift` +//! (exported by the StintIntents framework). When the framework isn't +//! loaded (CLI binary, headless tests, non-macOS), the helper returns +//! `None` and the [`verbs::start`] fallback treats it as "no current +//! focus" — the focus default is ignored. +//! +//! Test path: the `STINT_TEST_FOCUS_ID` env var, if set and non-empty, +//! short-circuits the dlsym lookup. This lets integration tests exercise +//! the focus fallback without a real Swift runtime. + +use std::ffi::CStr; +use std::os::raw::c_char; + +pub fn current_id() -> Option { + if let Ok(v) = std::env::var("STINT_TEST_FOCUS_ID") { + if !v.is_empty() { + return Some(v); + } + } + + let mut out: *mut c_char = std::ptr::null_mut(); + let rc = unsafe { crate::ffi::stint_current_focus_id(&mut out) }; + if rc != 0 || out.is_null() { + return None; + } + let s = unsafe { CStr::from_ptr(out).to_str().ok()?.to_owned() }; + unsafe { crate::ffi::stint_free_string(out) }; + if s.is_empty() { + None + } else { + Some(s) + } +} diff --git a/crates/stint-core/src/lib.rs b/crates/stint-core/src/lib.rs index c2b52e5..095aae6 100644 --- a/crates/stint-core/src/lib.rs +++ b/crates/stint-core/src/lib.rs @@ -6,6 +6,8 @@ pub mod calendar; pub mod config; pub mod error; +pub mod ffi; +pub mod focus; pub mod ids; pub mod oauth; pub mod paths; diff --git a/crates/stint-core/src/url_scheme.rs b/crates/stint-core/src/url_scheme.rs index 53c48a2..155bdef 100644 --- a/crates/stint-core/src/url_scheme.rs +++ b/crates/stint-core/src/url_scheme.rs @@ -1,10 +1,12 @@ //! Parse `stint://` URLs into a typed `Action`. //! -//! Supported forms (Phase 6a): +//! Supported forms: //! - `stint://start?description=…&project=…&task=…&billable=true` //! - `stint://stop` //! - `stint://entry/` (open in app) //! - `stint://current` (focus current entry view) +//! - `stint://project/` (Phase 6b — Spotlight tap on a project entity) +//! - `stint://task/` (Phase 6b — Spotlight tap on a task entity) use crate::{Error, Result}; use std::collections::HashMap; @@ -22,6 +24,12 @@ pub enum Action { local_uuid: String, }, Current, + OpenProject { + project_id: String, + }, + OpenTask { + task_id: String, + }, } pub fn parse(input: &str) -> Result { @@ -57,10 +65,27 @@ pub fn parse(input: &str) -> Result { "entry" => { let local_uuid = segments .next() + .filter(|s| !s.is_empty()) .ok_or_else(|| Error::Invariant("entry requires local_uuid".into()))? .to_string(); Ok(Action::OpenEntry { local_uuid }) } + "project" => { + let project_id = segments + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| Error::Invariant("project requires id".into()))? + .to_string(); + Ok(Action::OpenProject { project_id }) + } + "task" => { + let task_id = segments + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| Error::Invariant("task requires id".into()))? + .to_string(); + Ok(Action::OpenTask { task_id }) + } other => Err(Error::Invariant(format!("unknown stint action: {other}"))), } } diff --git a/crates/stint-core/src/verbs/delete_entry.rs b/crates/stint-core/src/verbs/delete_entry.rs index 9d81e6f..f963201 100644 --- a/crates/stint-core/src/verbs/delete_entry.rs +++ b/crates/stint-core/src/verbs/delete_entry.rs @@ -31,11 +31,17 @@ pub async fn delete_entry(store: &Store, local_uuid: &str) -> Result<()> { } let timer = TimerService::new(store.clone()); - match timer.delete(local_uuid).await { + let result = match timer.delete(local_uuid).await { Ok(()) => Ok(()), // Race: row vanished between the probe and the delete. Still a // success from the verb's point of view. Err(Error::NotFound(_)) => Ok(()), Err(e) => Err(e), + }; + + if result.is_ok() { + let payload = serde_json::json!({ "local_uuid": local_uuid }).to_string(); + crate::ffi::notify_indexer(crate::ffi::IndexerKind::EntryDeleted, &payload); } + result } diff --git a/crates/stint-core/src/verbs/mod.rs b/crates/stint-core/src/verbs/mod.rs index 9d4eb54..40a51b5 100644 --- a/crates/stint-core/src/verbs/mod.rs +++ b/crates/stint-core/src/verbs/mod.rs @@ -3,7 +3,7 @@ //! Every transport (CLI, Tauri command, HTTP, MCP) delegates here. Adding a //! new verb means a new submodule here + ≤20 LoC of wiring per transport. //! -//! See `docs/superpowers/specs/2026-05-23-stint-phase-6-deeper-integration-design.md#211-dry-principle`. +//! See `docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md`. pub mod current; pub mod delete_entry; diff --git a/crates/stint-core/src/verbs/start.rs b/crates/stint-core/src/verbs/start.rs index 78e6c5d..9e081ac 100644 --- a/crates/stint-core/src/verbs/start.rs +++ b/crates/stint-core/src/verbs/start.rs @@ -1,3 +1,4 @@ +use crate::config::Settings; use crate::store::entries::Entries; use crate::store::Store; use crate::timer::{StartArgs, TimerService}; @@ -8,12 +9,24 @@ use crate::Result; /// caller's responsibility — this verb is strict and returns an error if /// a timer is already running. (Restart-style behavior lives in a separate /// helper at the transport layer.) +/// +/// **Focus default fallback:** when `params.project_id` is `None`, this +/// looks up `focus.default_project` in settings — written by Swift's +/// `ProjectFocusFilter` when a macOS Focus filter activates. The stored +/// value is `"\t"`; the project_id is only applied +/// when the stored focus_id matches the currently-active focus (so a stale +/// default from a previous focus doesn't leak across focus mode changes). pub async fn start(store: &Store, params: StartParams) -> Result { + let project_id = match params.project_id.clone() { + Some(id) => Some(id), + None => resolve_focus_default(store).await, + }; + let timer = TimerService::new(store.clone()); let id = timer .start(StartArgs { description: params.description, - project_id: params.project_id, + project_id, task_id: params.task_id, billable: params.billable, source: params.source, @@ -27,5 +40,21 @@ pub async fn start(store: &Store, params: StartParams) -> Result { .await? .expect("just-inserted entry must exist"); - Ok(row.into()) + let view: EntryView = row.into(); + if let Ok(payload) = serde_json::to_string(&view) { + crate::ffi::notify_indexer(crate::ffi::IndexerKind::EntryStarted, &payload); + } + Ok(view) +} + +async fn resolve_focus_default(store: &Store) -> Option { + let settings = Settings::new(store.clone()); + let raw = settings.get("focus.default_project").await.ok().flatten()?; + let (stored_focus, project_id) = raw.split_once('\t')?; + let current = crate::focus::current_id()?; + if current == stored_focus { + Some(project_id.to_string()) + } else { + None + } } diff --git a/crates/stint-core/src/verbs/stop.rs b/crates/stint-core/src/verbs/stop.rs index a3ba8d6..2f3b6fb 100644 --- a/crates/stint-core/src/verbs/stop.rs +++ b/crates/stint-core/src/verbs/stop.rs @@ -13,5 +13,10 @@ pub async fn stop(store: &Store) -> Result { .get(&id) .await? .expect("just-stopped entry must exist"); - Ok(row.into()) + + let view: EntryView = row.into(); + if let Ok(payload) = serde_json::to_string(&view) { + crate::ffi::notify_indexer(crate::ffi::IndexerKind::EntryStopped, &payload); + } + Ok(view) } diff --git a/crates/stint-core/src/verbs/update_entry.rs b/crates/stint-core/src/verbs/update_entry.rs index b74aea0..f30d771 100644 --- a/crates/stint-core/src/verbs/update_entry.rs +++ b/crates/stint-core/src/verbs/update_entry.rs @@ -111,5 +111,9 @@ pub async fn update_entry(store: &Store, local_uuid: &str, patch: EntryPatch) -> .await?; } - Ok(row.into()) + let view: EntryView = row.into(); + if let Ok(payload) = serde_json::to_string(&view) { + crate::ffi::notify_indexer(crate::ffi::IndexerKind::EntryUpdated, &payload); + } + Ok(view) } diff --git a/crates/stint-core/tests/ffi_envelope.rs b/crates/stint-core/tests/ffi_envelope.rs new file mode 100644 index 0000000..40726d8 --- /dev/null +++ b/crates/stint-core/tests/ffi_envelope.rs @@ -0,0 +1,84 @@ +//! Envelope shape contract — every FFI fn must produce {ok:T} or {err:{code,message}}. + +use serde_json::Value; +use std::ffi::{c_char, CStr}; +use std::ptr; + +#[test] +fn envelope_ok_shape() { + let mut out: *mut c_char = ptr::null_mut(); + stint_core::ffi::write_envelope_for_test::(&mut out, Ok(serde_json::json!({ "a": 1 }))); + assert!(!out.is_null()); + let s = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["ok"]["a"], 1); + assert!(v.get("err").is_none()); + unsafe { stint_core::ffi::stint_free_string(out) }; +} + +#[test] +fn envelope_err_invariant_shape() { + let mut out: *mut c_char = ptr::null_mut(); + stint_core::ffi::write_envelope_for_test::( + &mut out, + Err(stint_core::Error::Invariant("nope".into())), + ); + let s = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["err"]["code"], 1); + assert_eq!(v["err"]["message"], "invariant violation: nope"); + unsafe { stint_core::ffi::stint_free_string(out) }; +} + +#[test] +fn envelope_err_not_found_shape() { + let mut out: *mut c_char = ptr::null_mut(); + stint_core::ffi::write_envelope_for_test::( + &mut out, + Err(stint_core::Error::NotFound("missing-uuid".into())), + ); + let s = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["err"]["code"], 2); + unsafe { stint_core::ffi::stint_free_string(out) }; +} + +#[test] +fn envelope_err_serialization_maps_to_code_4() { + // Synthesize a serde_json::Error and confirm it maps to code 4. + let bad: serde_json::Error = serde_json::from_str::("not a number").unwrap_err(); + let mut out: *mut c_char = ptr::null_mut(); + stint_core::ffi::write_envelope_for_test::(&mut out, Err(stint_core::Error::Serde(bad))); + let s = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["err"]["code"], 4); + unsafe { stint_core::ffi::stint_free_string(out) }; +} + +#[test] +fn envelope_err_other_maps_to_internal() { + let mut out: *mut c_char = ptr::null_mut(); + stint_core::ffi::write_envelope_for_test::( + &mut out, + Err(stint_core::Error::SolidtimeAuth), + ); + let s = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["err"]["code"], 99); + unsafe { stint_core::ffi::stint_free_string(out) }; +} + +#[test] +fn free_string_handles_null() { + // Must not segfault. + unsafe { stint_core::ffi::stint_free_string(ptr::null_mut()) }; +} + +#[test] +fn write_envelope_handles_null_out_param() { + // Should be a no-op, not a crash. + stint_core::ffi::write_envelope_for_test::( + ptr::null_mut(), + Ok(serde_json::json!({"a": 1})), + ); +} diff --git a/crates/stint-core/tests/ffi_panic_safety.rs b/crates/stint-core/tests/ffi_panic_safety.rs new file mode 100644 index 0000000..5fccfa8 --- /dev/null +++ b/crates/stint-core/tests/ffi_panic_safety.rs @@ -0,0 +1,23 @@ +//! A Rust panic across the FFI boundary must be caught by `catch_unwind` +//! and turned into a `code = -1` Panic envelope — never undefined behavior. + +use serde_json::Value; +use std::ffi::{c_char, CStr}; +use std::ptr; + +#[test] +fn panic_in_ffi_body_returns_envelope_not_segfault() { + let mut out: *mut c_char = ptr::null_mut(); + stint_core::ffi::panic_for_test(&mut out); + + assert!(!out.is_null(), "envelope must be written even on panic"); + let s = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["err"]["code"], -1); + assert!( + v["err"]["message"].as_str().unwrap().contains("test panic"), + "panic message should be surfaced; got: {}", + v["err"]["message"] + ); + unsafe { stint_core::ffi::stint_free_string(out) }; +} diff --git a/crates/stint-core/tests/ffi_settings.rs b/crates/stint-core/tests/ffi_settings.rs new file mode 100644 index 0000000..f422ac0 --- /dev/null +++ b/crates/stint-core/tests/ffi_settings.rs @@ -0,0 +1,110 @@ +//! Tests for the settings + log + focus_id FFI surfaces. + +use std::ffi::{c_char, CStr, CString}; +use std::ptr; +use tempfile::TempDir; + +struct DataDirGuard { + _tempdir: TempDir, + prev: Option, +} + +impl DataDirGuard { + fn new() -> Self { + let prev = std::env::var("STINT_DATA_DIR").ok(); + let tempdir = TempDir::new().expect("create tempdir"); + std::env::set_var("STINT_DATA_DIR", tempdir.path()); + Self { + _tempdir: tempdir, + prev, + } + } +} + +impl Drop for DataDirGuard { + fn drop(&mut self) { + match &self.prev { + Some(v) => std::env::set_var("STINT_DATA_DIR", v), + None => std::env::remove_var("STINT_DATA_DIR"), + } + } +} + +#[test] +fn settings_set_get_clear_round_trip() { + let _guard = DataDirGuard::new(); + let key = CString::new("focus.default_project").unwrap(); + let val = CString::new("focus-uuid-abc\tproject-uuid-xyz").unwrap(); + + let rc = unsafe { stint_core::ffi::stint_settings_set(key.as_ptr(), val.as_ptr()) }; + assert_eq!(rc, 0); + + let mut out: *mut c_char = ptr::null_mut(); + let rc = unsafe { stint_core::ffi::stint_settings_get(key.as_ptr(), &mut out) }; + assert_eq!(rc, 0); + assert!(!out.is_null()); + let got = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + assert_eq!(got, "focus-uuid-abc\tproject-uuid-xyz"); + unsafe { stint_core::ffi::stint_free_string(out) }; + + let rc = unsafe { stint_core::ffi::stint_settings_clear(key.as_ptr()) }; + assert_eq!(rc, 0); + + let mut out: *mut c_char = ptr::null_mut(); + let rc = unsafe { stint_core::ffi::stint_settings_get(key.as_ptr(), &mut out) }; + assert_eq!(rc, 0); + assert!(out.is_null(), "cleared key must return null pointer"); +} + +#[test] +fn settings_get_missing_key_returns_null() { + let _guard = DataDirGuard::new(); + let key = CString::new("absent.key").unwrap(); + let mut out: *mut c_char = ptr::null_mut(); + let rc = unsafe { stint_core::ffi::stint_settings_get(key.as_ptr(), &mut out) }; + assert_eq!(rc, 0); + assert!(out.is_null()); +} + +#[test] +fn settings_null_pointers_return_misuse() { + let key = CString::new("k").unwrap(); + let rc = unsafe { stint_core::ffi::stint_settings_set(ptr::null(), key.as_ptr()) }; + assert_eq!(rc, -2); + let rc = unsafe { stint_core::ffi::stint_settings_set(key.as_ptr(), ptr::null()) }; + assert_eq!(rc, -2); + let rc = unsafe { stint_core::ffi::stint_settings_get(ptr::null(), ptr::null_mut()) }; + assert_eq!(rc, -2); + let rc = unsafe { stint_core::ffi::stint_settings_clear(ptr::null()) }; + assert_eq!(rc, -2); +} + +#[test] +fn log_warn_does_not_panic() { + let msg = CString::new("hello from swift").unwrap(); + unsafe { stint_core::ffi::stint_log_warn(msg.as_ptr()) }; + unsafe { stint_core::ffi::stint_log_warn(ptr::null()) }; +} + +#[test] +fn current_focus_id_returns_null_in_tests() { + let mut out: *mut c_char = ptr::null_mut(); + let rc = unsafe { stint_core::ffi::stint_current_focus_id(&mut out) }; + assert_eq!(rc, 0); + // In tests the dlsym lookup returns null (Swift framework isn't loaded). + assert!(out.is_null()); +} + +#[test] +fn notify_indexer_is_noop_when_swift_absent() { + // No assertion — just that it doesn't crash without a Swift framework loaded. + stint_core::ffi::notify_indexer( + stint_core::ffi::IndexerKind::EntryStarted, + r#"{"local_uuid":"u1"}"#, + ); + stint_core::ffi::notify_indexer( + stint_core::ffi::IndexerKind::EntryStopped, + r#"{"local_uuid":"u1"}"#, + ); + stint_core::ffi::notify_indexer(stint_core::ffi::IndexerKind::ProjectsReplaced, "[]"); +} diff --git a/crates/stint-core/tests/ffi_verbs.rs b/crates/stint-core/tests/ffi_verbs.rs new file mode 100644 index 0000000..06ba255 --- /dev/null +++ b/crates/stint-core/tests/ffi_verbs.rs @@ -0,0 +1,215 @@ +//! Integration tests for the 8 extern "C" verb wrappers. +//! +//! Each test sets STINT_DATA_DIR to its own tempdir so the FFI's lazy store +//! cache opens against a fresh SQLite. cargo test --test-threads=1 ensures +//! sequential execution (env var manipulation isn't thread-safe). + +use serde_json::Value; +use std::ffi::{c_char, CStr, CString}; +use std::ptr; +use tempfile::TempDir; + +/// Test guard that points STINT_DATA_DIR at a fresh tempdir. Drop restores +/// the previous value so other tests in the same process don't bleed state. +struct DataDirGuard { + _tempdir: TempDir, + prev: Option, +} + +impl DataDirGuard { + fn new() -> Self { + let prev = std::env::var("STINT_DATA_DIR").ok(); + let tempdir = TempDir::new().expect("create tempdir"); + std::env::set_var("STINT_DATA_DIR", tempdir.path()); + Self { + _tempdir: tempdir, + prev, + } + } +} + +impl Drop for DataDirGuard { + fn drop(&mut self) { + match &self.prev { + Some(v) => std::env::set_var("STINT_DATA_DIR", v), + None => std::env::remove_var("STINT_DATA_DIR"), + } + } +} + +fn call_with_params( + verb: unsafe extern "C" fn(*const c_char, *mut *mut c_char) -> i32, + params: &str, +) -> Value { + let cstr = CString::new(params).unwrap(); + let mut out: *mut c_char = ptr::null_mut(); + let rc = unsafe { verb(cstr.as_ptr(), &mut out) }; + assert_eq!(rc, 0, "FFI return code: {rc}"); + decode_envelope(out) +} + +fn call_no_params(verb: unsafe extern "C" fn(*mut *mut c_char) -> i32) -> Value { + let mut out: *mut c_char = ptr::null_mut(); + let rc = unsafe { verb(&mut out) }; + assert_eq!(rc, 0, "FFI return code: {rc}"); + decode_envelope(out) +} + +fn decode_envelope(out: *mut c_char) -> Value { + assert!(!out.is_null()); + let s = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + let v: Value = serde_json::from_str(&s).unwrap_or_else(|e| panic!("bad envelope: {s}: {e}")); + unsafe { stint_core::ffi::stint_free_string(out) }; + v +} + +#[test] +fn ffi_start_happy_path() { + let _guard = DataDirGuard::new(); + let env = call_with_params( + stint_core::ffi::stint_verb_start, + r#"{"description":"writing tests","source":"ffi-test"}"#, + ); + assert!(env["ok"].is_object(), "envelope: {env}"); + assert_eq!(env["ok"]["description"], "writing tests"); + assert_eq!(env["ok"]["source"], "ffi-test"); + assert!(env["ok"]["local_uuid"].is_string()); +} + +#[test] +fn ffi_start_invariant_already_running() { + let _guard = DataDirGuard::new(); + let first = call_with_params( + stint_core::ffi::stint_verb_start, + r#"{"description":"first","source":"ffi-test"}"#, + ); + assert!( + first["ok"].is_object(), + "first start should succeed: {first}" + ); + + let env = call_with_params( + stint_core::ffi::stint_verb_start, + r#"{"description":"second","source":"ffi-test"}"#, + ); + assert_eq!(env["err"]["code"], 1, "envelope: {env}"); +} + +#[test] +fn ffi_current_when_running() { + let _guard = DataDirGuard::new(); + let _ = call_with_params( + stint_core::ffi::stint_verb_start, + r#"{"description":"x","source":"ffi-test"}"#, + ); + let env = call_no_params(stint_core::ffi::stint_verb_current); + // current returns Option — Some(view) + assert_eq!(env["ok"]["description"], "x"); +} + +#[test] +fn ffi_current_when_no_timer() { + let _guard = DataDirGuard::new(); + let env = call_no_params(stint_core::ffi::stint_verb_current); + // Option::None serializes to null + assert!(env["ok"].is_null(), "envelope: {env}"); +} + +#[test] +fn ffi_stop_after_start() { + let _guard = DataDirGuard::new(); + let _ = call_with_params( + stint_core::ffi::stint_verb_start, + r#"{"description":"y","source":"ffi-test"}"#, + ); + let env = call_no_params(stint_core::ffi::stint_verb_stop); + assert!(env["ok"]["end_at"].is_string(), "envelope: {env}"); +} + +#[test] +fn ffi_stop_with_no_running_timer_errors() { + let _guard = DataDirGuard::new(); + let env = call_no_params(stint_core::ffi::stint_verb_stop); + assert!(env.get("err").is_some(), "envelope: {env}"); +} + +#[test] +fn ffi_list_entries_empty() { + let _guard = DataDirGuard::new(); + let env = call_with_params(stint_core::ffi::stint_verb_list_entries, "{}"); + assert!(env["ok"].is_array(), "envelope: {env}"); + assert_eq!(env["ok"].as_array().unwrap().len(), 0); +} + +#[test] +fn ffi_list_projects_empty() { + let _guard = DataDirGuard::new(); + let env = call_no_params(stint_core::ffi::stint_verb_list_projects); + assert!(env["ok"].is_array(), "envelope: {env}"); +} + +#[test] +fn ffi_list_tasks_empty() { + let _guard = DataDirGuard::new(); + let env = call_with_params(stint_core::ffi::stint_verb_list_tasks, "{}"); + assert!(env["ok"].is_array(), "envelope: {env}"); +} + +#[test] +fn ffi_update_entry_not_found() { + let _guard = DataDirGuard::new(); + let env = call_with_params( + stint_core::ffi::stint_verb_update_entry, + r#"{"local_uuid":"does-not-exist","patch":{}}"#, + ); + assert_eq!(env["err"]["code"], 2, "envelope: {env}"); +} + +#[test] +fn ffi_delete_entry_is_idempotent() { + // verbs::delete_entry intentionally treats a missing row as success + // (the verb contract is "ensure it's gone"). The FFI envelope mirrors + // that — ok payload is `{}`. + let _guard = DataDirGuard::new(); + let env = call_with_params( + stint_core::ffi::stint_verb_delete_entry, + r#"{"local_uuid":"does-not-exist"}"#, + ); + assert_eq!(env["ok"], serde_json::json!({}), "envelope: {env}"); +} + +#[test] +fn ffi_delete_entry_actually_removes() { + let _guard = DataDirGuard::new(); + let started = call_with_params( + stint_core::ffi::stint_verb_start, + r#"{"description":"to delete","source":"ffi-test"}"#, + ); + let uuid = started["ok"]["local_uuid"].as_str().unwrap().to_owned(); + let _ = call_no_params(stint_core::ffi::stint_verb_stop); + + let payload = format!(r#"{{"local_uuid":"{uuid}"}}"#); + let env = call_with_params(stint_core::ffi::stint_verb_delete_entry, &payload); + assert_eq!(env["ok"], serde_json::json!({}), "delete envelope: {env}"); + + // Verify the entry is gone via list_entries. + let list = call_with_params(stint_core::ffi::stint_verb_list_entries, "{}"); + assert_eq!(list["ok"].as_array().unwrap().len(), 0); +} + +#[test] +fn ffi_start_malformed_json_returns_serialization_error() { + let _guard = DataDirGuard::new(); + let env = call_with_params(stint_core::ffi::stint_verb_start, "not json"); + assert_eq!(env["err"]["code"], 4, "envelope: {env}"); +} + +#[test] +fn ffi_start_null_params_returns_invariant_error() { + let _guard = DataDirGuard::new(); + let mut out: *mut c_char = ptr::null_mut(); + let rc = unsafe { stint_core::ffi::stint_verb_start(ptr::null(), &mut out) }; + assert_eq!(rc, 0); + let env = decode_envelope(out); + assert_eq!(env["err"]["code"], 1, "envelope: {env}"); +} diff --git a/crates/stint-core/tests/focus_fallback.rs b/crates/stint-core/tests/focus_fallback.rs new file mode 100644 index 0000000..911bb70 --- /dev/null +++ b/crates/stint-core/tests/focus_fallback.rs @@ -0,0 +1,156 @@ +//! Tests for the focus-default fallback in verbs::start. +//! +//! The fallback reads `focus.default_project` (a "\t" +//! tuple written by Swift's ProjectFocusFilter) and applies it ONLY when +//! the stored focus_id matches the currently-active focus, so stale +//! defaults from previous focus modes don't leak. + +mod common; + +use stint_core::{config::Settings, verbs}; + +struct FocusGuard; +impl FocusGuard { + fn set(value: &str) { + std::env::set_var("STINT_TEST_FOCUS_ID", value); + } + fn clear() { + std::env::remove_var("STINT_TEST_FOCUS_ID"); + } +} +impl Drop for FocusGuard { + fn drop(&mut self) { + Self::clear(); + } +} + +fn start_params(desc: &str, project_id: Option<&str>) -> verbs::StartParams { + verbs::StartParams { + description: desc.into(), + project_id: project_id.map(str::to_string), + task_id: None, + billable: false, + start_at: None, + source: "focus-test".into(), + } +} + +#[tokio::test] +async fn start_picks_up_focus_default_when_project_missing() { + let env = common::setup().await; + common::seed_projects(&env.store, &[("proj-uuid-1", "Acme")]).await; + + Settings::new(env.store.clone()) + .set("focus.default_project", "fake-focus-id\tproj-uuid-1") + .await + .unwrap(); + + FocusGuard::set("fake-focus-id"); + let _guard = FocusGuard; + + let view = verbs::start(&env.store, start_params("no project given", None)) + .await + .unwrap(); + + assert_eq!(view.project_id.as_deref(), Some("proj-uuid-1")); +} + +#[tokio::test] +async fn start_ignores_focus_default_when_focus_id_mismatches() { + let env = common::setup().await; + common::seed_projects(&env.store, &[("proj-uuid-1", "Acme")]).await; + + Settings::new(env.store.clone()) + .set("focus.default_project", "fake-focus-id\tproj-uuid-1") + .await + .unwrap(); + + FocusGuard::set("different-focus-id"); + let _guard = FocusGuard; + + let view = verbs::start(&env.store, start_params("no project given", None)) + .await + .unwrap(); + + assert_eq!(view.project_id, None); +} + +#[tokio::test] +async fn start_explicit_project_overrides_focus_default() { + let env = common::setup().await; + common::seed_projects( + &env.store, + &[("proj-uuid-1", "Acme"), ("proj-uuid-2", "Other")], + ) + .await; + + Settings::new(env.store.clone()) + .set("focus.default_project", "fake-focus-id\tproj-uuid-1") + .await + .unwrap(); + + FocusGuard::set("fake-focus-id"); + let _guard = FocusGuard; + + let view = verbs::start( + &env.store, + start_params("explicit project", Some("proj-uuid-2")), + ) + .await + .unwrap(); + + assert_eq!(view.project_id.as_deref(), Some("proj-uuid-2")); +} + +#[tokio::test] +async fn start_no_focus_default_no_project_applied() { + let env = common::setup().await; + // No focus.default_project key set. + + // No STINT_TEST_FOCUS_ID set either. + FocusGuard::clear(); + + let view = verbs::start(&env.store, start_params("vanilla", None)) + .await + .unwrap(); + + assert_eq!(view.project_id, None); +} + +#[tokio::test] +async fn start_focus_default_with_no_active_focus_is_ignored() { + let env = common::setup().await; + common::seed_projects(&env.store, &[("proj-uuid-1", "Acme")]).await; + + Settings::new(env.store.clone()) + .set("focus.default_project", "stored-focus\tproj-uuid-1") + .await + .unwrap(); + + // No active focus. + FocusGuard::clear(); + + let view = verbs::start(&env.store, start_params("v", None)) + .await + .unwrap(); + + assert_eq!(view.project_id, None); +} + +#[tokio::test] +async fn start_focus_default_with_malformed_tuple_is_ignored() { + let env = common::setup().await; + Settings::new(env.store.clone()) + .set("focus.default_project", "no-tab-separator-here") + .await + .unwrap(); + + FocusGuard::set("any-focus"); + let _guard = FocusGuard; + + let view = verbs::start(&env.store, start_params("v", None)) + .await + .unwrap(); + + assert_eq!(view.project_id, None); +} diff --git a/crates/stint-core/tests/url_scheme.rs b/crates/stint-core/tests/url_scheme.rs index f9f1c00..ce371a8 100644 --- a/crates/stint-core/tests/url_scheme.rs +++ b/crates/stint-core/tests/url_scheme.rs @@ -68,6 +68,30 @@ fn parse_percent_decodes_special_chars() { } } +#[test] +fn parse_open_project() { + let action = parse("stint://project/proj-uuid-1").unwrap(); + assert!(matches!(action, Action::OpenProject { project_id } if project_id == "proj-uuid-1")); +} + +#[test] +fn parse_open_task() { + let action = parse("stint://task/task-uuid-1").unwrap(); + assert!(matches!(action, Action::OpenTask { task_id } if task_id == "task-uuid-1")); +} + +#[test] +fn parse_open_project_missing_id_errors() { + assert!(parse("stint://project").is_err()); + assert!(parse("stint://project/").is_err()); +} + +#[test] +fn parse_open_task_missing_id_errors() { + assert!(parse("stint://task").is_err()); + assert!(parse("stint://task/").is_err()); +} + #[test] fn parse_percent_decodes_multibyte_utf8() { // `café` in UTF-8 is c, a, f, é → 63 61 66 c3 a9. The é must round-trip diff --git a/docs/superpowers/plans/2026-05-25-stint-phase-6b-spotlight-app-intents.md b/docs/superpowers/plans/2026-05-25-stint-phase-6b-spotlight-app-intents.md new file mode 100644 index 0000000..ccd763d --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-stint-phase-6b-spotlight-app-intents.md @@ -0,0 +1,3799 @@ +# stint Phase 6b: Spotlight + App Intents Implementation Plan + +> **2026-05-26 ship status — read this before editing the plan.** +> Phase 6b shipped as **foundation-only**. The Rust FFI bridge (`stint_core::ffi`), URL scheme additions, focus-default fallback in `verbs::start`, and the Swift package (static-linked into stint-app's main binary) all shipped and tested. The user-facing Siri / Spotlight / Focus-filter surfaces did NOT activate — Apple's intent indexer remains silent on the bundle despite real Developer ID signing, notarization, app-level Metadata.appintents, and Swift type metadata correctly present in the main binary. The deferral is documented in `docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md` §1.5. A follow-up phase using Xcode's App Intents Extension target template will re-enable those surfaces. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land `StintIntents.framework` inside `Stint.app` so macOS Spotlight indexes entries/projects/tasks, App Intents expose all 8 verbs as Custom Shortcuts (with 5 of them promoted as App Shortcuts with voice phrases), and a Focus filter sets the default project for new timers per Focus mode. + +**Architecture:** A Swift Package at `crates/stint-app/swift/StintIntents/` produces a dynamic framework that is embedded into the Tauri-built `.app` via `bundle.macOS.frameworks`. Bidirectional FFI: Rust exposes `extern "C"` JSON-in/JSON-out verb wrappers; Swift exposes `@_cdecl` symbols looked up via `dlsym`. Spotlight indexing fires on every verb mutation through a Rust→Swift callback (no-op when the framework isn't loaded — keeps the CLI binary Spotlight-unaware). + +**Tech Stack:** Swift 5.9+ · App Intents framework (macOS 13+) · Core Spotlight · NSUserActivity · Swift Package Manager (with `xcodebuild` fallback) · Rust 1.95.0 · existing Tauri 2 / SolidJS stack. + +**Spec:** [`docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md`](../specs/2026-05-25-stint-phase-6-deeper-integration-design.md) + +--- + +## File Structure + +### Rust crates + +**Modify:** +- `crates/stint-core/src/lib.rs` — register `ffi` module +- `crates/stint-core/src/verbs/start.rs` — add focus-default fallback +- `crates/stint-core/src/verbs/list_tasks.rs` — accept "no project_id = all" +- `crates/stint-core/src/url_scheme.rs` — add `OpenProject`, `OpenTask` +- `crates/stint-app/src/lib.rs` — call `stint_intents_init()` from `setup()` +- `crates/stint-app/src/pull_worker.rs` — call `notify_indexer(ProjectsReplaced/TasksReplaced)` +- `crates/stint-app/build.rs` — invoke `swift build`, copy framework +- `crates/stint-app/tauri.conf.json` — `bundle.macOS.frameworks` +- `crates/stint-cli/skills/stint/SKILL.md` — App Intents surface ladder + +**Create:** +- `crates/stint-core/src/ffi.rs` — extern "C" envelope + 8 verb wrappers + settings + log + focus +- `crates/stint-core/include/stint_core.h` — hand-written C header for Swift bridging +- `crates/stint-core/tests/ffi_envelope.rs` — envelope shape tests +- `crates/stint-core/tests/ffi_verbs.rs` — verb wrapper tests +- `crates/stint-core/tests/ffi_panic_safety.rs` — `catch_unwind` test + +### Swift package + +**Create the entire tree under `crates/stint-app/swift/StintIntents/`:** + +``` +Package.swift +Sources/StintIntents/ + Bridge.swift # FFI declarations + Bridge protocol + FFIBridge + Errors/BridgeError.swift # IntentError + envelope decode helper + Entities/ + EntryEntity.swift + ProjectEntity.swift + TaskEntity.swift + EntryQuery.swift + ProjectQuery.swift + TaskQuery.swift + Intents/ + StartTimerIntent.swift + StopTimerIntent.swift + GetCurrentIntent.swift + SwitchProjectIntent.swift + LogPastIntent.swift + ListEntriesIntent.swift + ListProjectsIntent.swift + ListTasksIntent.swift + UpdateEntryIntent.swift + DeleteEntryIntent.swift + Shortcuts/ + StintAppShortcutsProvider.swift + PhraseStrings.xcstrings + Spotlight/ + SpotlightIndexer.swift + ActivityTracker.swift + Focus/ + ProjectFocusFilter.swift + Init/ + StintIntentsInit.swift # @_cdecl stint_intents_init, swift_indexer_notify, stint_current_focus_id +Tests/StintIntentsTests/ + BridgeEnvelopeTests.swift + EntityCodingTests.swift + SpotlightSchemaTests.swift + AppIntentPerformTests.swift + ProjectQueryTests.swift +Tests/StintIntentsIntegrationTests/ # separate target — links real stint_core + FFIRoundTripTests.swift +``` + +### CI / docs + +- `.github/workflows/ci.yml` — add `swift test` step, `codesign --verify` post-bundle step, Metadata.appintents parse + count assertion +- `docs/superpowers/plans/2026-05-25-stint-phase-6b-spotlight-app-intents.md` — this file +- Update `crates/stint-cli/skills/stint/SKILL.md` with App Intents surface ladder + +--- + +## Conventions used throughout + +- **TDD discipline:** Rust changes follow `failing test → impl → green` per the project standard (`crates/stint-core/tests/`). +- **Commit per task** with Conventional Commits. Subject under 70 chars; body explains the *why*. Use `feat(swift):`, `feat(ffi):`, `chore(build):`, etc. +- **Run before each commit:** the touched test file (`cargo test -p stint-core ffi_envelope` etc.) plus `cargo fmt --all -- --check` if any Rust file was edited. Full gate (`cargo test --workspace -- --test-threads=1`, `cargo clippy --workspace --all-targets -- -D warnings`, `pnpm typecheck`, `scripts/coverage.sh`) runs **once** at end of plan. +- **Don't push or open the PR** until the user confirms. +- **`scripts/dev-cli.sh` and `scripts/dev-app.sh`** wrap codesigning so the macOS Keychain ACL doesn't re-prompt. Don't use raw `cargo run` for the CLI or `cargo tauri dev` directly — use the wrappers. + +--- + +## Task A1: SPM spike — verify framework + AppShortcut metadata generates ✅ PASSED + +**Result (2026-05-25):** Build pipeline locked in. Findings recorded inline so subsequent tasks (especially H1) reference the right tool and paths. + +**Key findings:** + +1. **`swift build` does NOT run `appintentsmetadataprocessor`.** The dylib it produces has Swift type metadata for the intent types but no `Metadata.appintents` stencil that macOS uses for OS-level intent discovery at install time. +2. **`xcodebuild` against `Package.swift` (no .xcodeproj needed) DOES run the processor.** Command: + ```bash + xcodebuild -scheme StintIntents -configuration Release \ + -destination 'platform=macOS' \ + -derivedDataPath ./build/derived + ``` +3. **App Shortcut phrase rule (enforced by the processor):** every phrase must contain `\(.applicationName)`. Build fails otherwise with `Invalid Utterance. Every App Shortcut utterance should have one '${applicationName}' in it.` +4. **Output paths** (relative to derived data root): + - Framework: `Build/Products/Release/PackageFrameworks/StintIntents.framework/` + - Metadata bundle (separate from framework): `Build/Products/Release/StintIntents.appintents/Metadata.appintents/` +5. **Stencil format:** the `Metadata.appintents` directory contains `version.json` + `extract.actionsdata` (JSON, listing each `AppIntent` type, its phrases, and the `autoShortcutProviderMangledName`). +6. **Critical packaging step:** Xcode does NOT auto-inject the `Metadata.appintents` into the framework. Task H1's `build.rs` must copy it into `StintIntents.framework/Versions/A/Resources/Metadata.appintents/` so macOS can discover the intents when the framework is embedded in `Stint.app/Contents/Frameworks/`. +7. **Framework's Info.plist needs `NSAppIntentsPackage=YES`** for the OS to scan the embedded framework for intent metadata. SPM-generated frameworks don't include this key — build.rs must inject it. + +**No commit for the spike itself** — only the Package.swift seed and the `.gitignore` for `.build/`, `build/`, `.swiftpm/` are committed (and an empty `.gitkeep` to retain the Sources/StintIntents/ directory). + +Subsequent tasks reference these findings: +- Task H1: `cargo build -p stint-app` runs `xcodebuild` (not `swift build`), then copies `Metadata.appintents` into the framework's Resources and patches the Info.plist. +- Task G1: every App Shortcut phrase must include `\(.applicationName)`. +- Task J3: CI gate parses `Metadata.appintents/extract.actionsdata` JSON (not strings-based fingerprinting) — it's a proper JSON document. + +**Original spike steps preserved below for reference; A1.fallback (Xcode .xcodeproj) is no longer needed.** + +**Files:** +- Create: `crates/stint-app/swift/StintIntents/Package.swift` (throwaway version) +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/SpikeIntent.swift` (throwaway) + +- [ ] **Step 1: Scaffold the SPM package** + +Run: +```bash +mkdir -p crates/stint-app/swift/StintIntents/Sources/StintIntents +cd crates/stint-app/swift/StintIntents +``` + +Write `Package.swift`: + +```swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "StintIntents", + platforms: [.macOS(.v13)], + products: [ + .library(name: "StintIntents", type: .dynamic, targets: ["StintIntents"]), + ], + targets: [ + .target( + name: "StintIntents", + path: "Sources/StintIntents" + ), + ] +) +``` + +Write `Sources/StintIntents/SpikeIntent.swift`: + +```swift +import AppIntents +import Foundation + +struct SpikeIntent: AppIntent { + static var title: LocalizedStringResource = "Spike" + static var description = IntentDescription("Throwaway spike to verify SPM produces AppIntents metadata.") + func perform() async throws -> some IntentResult { .result() } +} + +struct StintSpikeShortcutsProvider: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: SpikeIntent(), + phrases: ["Spike in Stint"], + shortTitle: "Spike", + systemImageName: "checkmark.circle" + ) + } +} +``` + +- [ ] **Step 2: Build the package** + +Run from `crates/stint-app/swift/StintIntents/`: +```bash +swift build -c release +``` + +Expected: clean build, no errors. Output framework path: `.build/release/StintIntents.framework` or `.build/arm64-apple-macosx/release/StintIntents.dylib` depending on SPM output mode. + +If SPM emits a `.dylib` instead of `.framework`, that's fine for the spike — what matters is the metadata stencil. + +- [ ] **Step 3: Locate the AppIntents metadata stencil** + +Run: +```bash +find .build -name "Metadata.appintents" -o -name "*.appintentsmetadata*" 2>/dev/null +``` + +Expected: at least one match. If empty → SPM didn't run `appintentsmetadataprocessor` automatically. Try: + +```bash +find .build -name "*.appintents" -o -name "ExtractAppIntentsMetadata*" 2>/dev/null +swift build -c release -Xswiftc -j1 2>&1 | grep -i "appintents" +``` + +If still nothing → **SPM spike failed**; jump to Task A1.fallback. + +- [ ] **Step 4: Verify metadata stencil mentions our intent** + +Run (path adjusted to wherever step 3 found the stencil): +```bash +strings .build/release/StintIntents.framework/Resources/Metadata.appintents 2>/dev/null | grep -i Spike +# or, for the bare dylib output: +strings .build/release/libStintIntents.dylib 2>/dev/null | grep -i Spike +``` + +Expected: matches for `SpikeIntent`, `StintSpikeShortcutsProvider`, and the phrase `Spike in Stint`. + +- [ ] **Step 5: Decision point — clean up spike files** + +If steps 3+4 succeeded → **commit nothing**; instead, delete the spike sources: + +```bash +rm crates/stint-app/swift/StintIntents/Sources/StintIntents/SpikeIntent.swift +# leave Package.swift in place — Task C1 will overwrite it with the final version +``` + +If they failed → switch to Task A1.fallback (Xcode `.xcodeproj`) and tag this task with a note in the commit message of A2 documenting what SPM didn't generate. + +- [ ] **Step 6: Capture findings in a commit message preview** + +The next commit (Task A2 or A1.fallback) will reference this finding. Note in a scratch file `/tmp/spm_spike.txt`: + +``` +SPM result: +Stencil path: +Notes: +``` + +No commit for this task on its own — the spike is exploratory. + +--- + +## Task A1.fallback — N/A + +Spike passed with the SPM+xcodebuild hybrid. The Xcode `.xcodeproj` fallback is not needed. + +--- + +## Task A2: Rust FFI envelope + panic safety + +**Goal:** Create the FFI module skeleton with envelope JSON helpers and a `catch_unwind`-wrapped invocation pattern. No verbs yet — just the plumbing. + +**Files:** +- Create: `crates/stint-core/src/ffi.rs` +- Modify: `crates/stint-core/src/lib.rs` (register module) +- Create: `crates/stint-core/tests/ffi_envelope.rs` +- Create: `crates/stint-core/tests/ffi_panic_safety.rs` + +- [ ] **Step 1: Write failing envelope test** + +Create `crates/stint-core/tests/ffi_envelope.rs`: + +```rust +//! Envelope shape contract — every FFI verb wraps results in {"ok": T} or {"err": {code, message}}. + +use serde_json::Value; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +// Re-export the helper we'll add in stint_core::ffi. +use stint_core::ffi::{ + self, stint_free_string, write_envelope_for_test, +}; + +#[test] +fn envelope_ok_shape() { + let mut out: *mut c_char = ptr::null_mut(); + write_envelope_for_test(&mut out, Ok::<_, stint_core::Error>(serde_json::json!({"a": 1}))); + assert!(!out.is_null()); + let s = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["ok"]["a"], 1); + assert!(v.get("err").is_none()); + unsafe { stint_free_string(out) }; +} + +#[test] +fn envelope_err_invariant_shape() { + let mut out: *mut c_char = ptr::null_mut(); + write_envelope_for_test::( + &mut out, + Err(stint_core::Error::Invariant("nope".into())), + ); + let s = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["err"]["code"], 1); + assert_eq!(v["err"]["message"], "nope"); + unsafe { stint_free_string(out) }; +} + +#[test] +fn free_string_handles_null() { + unsafe { stint_free_string(ptr::null_mut()) }; // must not segfault +} +``` + +- [ ] **Step 2: Run test to confirm failure** + +```bash +cargo test -p stint-core --test ffi_envelope 2>&1 | tail -20 +``` + +Expected: compile error — `stint_core::ffi` module doesn't exist. + +- [ ] **Step 3: Create the ffi module** + +Write `crates/stint-core/src/ffi.rs`: + +```rust +//! C ABI surface for Swift consumers (StintIntents framework). +//! +//! Every public `extern "C"` function returns 0 (success — the actual result +//! is JSON-encoded in `out_json`) or a small set of misuse codes. The JSON +//! envelope is always one of: +//! +//! ```json +//! {"ok": } +//! {"err": {"code": , "message": ""}} +//! ``` +//! +//! Codes are a stable public contract — see the spec table. +//! +//! All public FFI fns wrap their body in `catch_unwind` so a Rust panic +//! crossing the C ABI becomes an `err.code = -1` envelope instead of UB. + +use crate::Error; +use serde::Serialize; +use std::ffi::{c_char, CString}; +use std::panic; +use std::ptr; + +/// Stable error-code contract. Never renumber. +#[repr(i32)] +#[derive(Debug, Clone, Copy)] +enum Code { + Invariant = 1, + NotFound = 2, + Conflict = 3, + Serialization = 4, + Internal = 99, + Panic = -1, +} + +fn code_for(err: &Error) -> i32 { + match err { + Error::Invariant(_) => Code::Invariant as i32, + Error::NotFound(_) => Code::NotFound as i32, + Error::SyncConflict(_) => Code::Conflict as i32, + Error::Serialization(_) => Code::Serialization as i32, + _ => Code::Internal as i32, + } +} + +/// Build the envelope JSON for any `Result` and write a malloc'd +/// CString into `*out_json`. Caller (Swift) frees via `stint_free_string`. +fn write_envelope(out_json: *mut *mut c_char, result: Result) { + if out_json.is_null() { + return; + } + let body = match result { + Ok(t) => serde_json::json!({ "ok": t }), + Err(e) => serde_json::json!({ + "err": { "code": code_for(&e), "message": e.to_string() } + }), + }; + let s = body.to_string(); + let c = CString::new(s).unwrap_or_else(|_| CString::new("{\"err\":{\"code\":99,\"message\":\"cstring null\"}}").unwrap()); + unsafe { *out_json = c.into_raw() }; +} + +/// Test-only re-export so integration tests can exercise the envelope helper +/// without needing a verb context. +#[doc(hidden)] +pub fn write_envelope_for_test(out_json: *mut *mut c_char, result: Result) +where + E: Into, +{ + let mapped = result.map_err(Into::into); + write_envelope(out_json, mapped); +} + +/// Wrap an FFI body in `catch_unwind`. On panic, write a Panic envelope. +fn ffi_body(out_json: *mut *mut c_char, f: F) +where + F: FnOnce() -> Result + std::panic::UnwindSafe, + T: Serialize, +{ + let result = panic::catch_unwind(f); + match result { + Ok(r) => write_envelope(out_json, r), + Err(p) => { + let msg = match p.downcast_ref::<&'static str>() { + Some(s) => (*s).to_owned(), + None => match p.downcast_ref::() { + Some(s) => s.clone(), + None => "rust panic (no message)".into(), + }, + }; + let body = serde_json::json!({ + "err": { "code": Code::Panic as i32, "message": msg } + }); + let c = CString::new(body.to_string()).unwrap(); + if !out_json.is_null() { + unsafe { *out_json = c.into_raw() }; + } + } + } +} + +/// Free a CString previously returned via `*out_json`. Safe to call with NULL. +#[no_mangle] +pub unsafe extern "C" fn stint_free_string(ptr: *mut c_char) { + if ptr.is_null() { + return; + } + let _ = CString::from_raw(ptr); +} + +// Verbs are added in Task A3. +``` + +Add to `crates/stint-core/src/lib.rs` (next to existing `pub mod` declarations): + +```rust +pub mod ffi; +``` + +- [ ] **Step 4: Run tests to confirm green** + +```bash +cargo test -p stint-core --test ffi_envelope 2>&1 | tail -10 +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: Write panic-safety test** + +Create `crates/stint-core/tests/ffi_panic_safety.rs`: + +```rust +//! A Rust panic across the FFI boundary must be caught and turned into a +//! Panic envelope (code = -1) — never undefined behavior. + +use serde_json::Value; +use std::ffi::{c_char, CStr}; +use std::ptr; + +#[test] +fn panic_in_ffi_body_returns_envelope_not_segfault() { + // We exercise ffi_body indirectly via a temporary helper added below. + // Once Task A3 lands the real verbs, this is replaced by a verb-level + // panic-injection test. For A2, this asserts the wrapper itself works. + + let mut out: *mut c_char = ptr::null_mut(); + stint_core::ffi::panic_for_test(&mut out); + + assert!(!out.is_null(), "envelope must be written even on panic"); + let s = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["err"]["code"], -1); + assert!(v["err"]["message"].as_str().unwrap().contains("test panic")); + unsafe { stint_core::ffi::stint_free_string(out) }; +} +``` + +Add to the bottom of `crates/stint-core/src/ffi.rs`: + +```rust +/// Test-only — trigger ffi_body's panic path so the catch_unwind branch is +/// exercised. Not compiled into release builds. +#[doc(hidden)] +pub fn panic_for_test(out_json: *mut *mut c_char) { + ffi_body::<_, ()>(out_json, || panic!("test panic")); +} +``` + +- [ ] **Step 6: Run panic-safety test** + +```bash +cargo test -p stint-core --test ffi_panic_safety 2>&1 | tail -10 +``` + +Expected: 1 test passes. + +- [ ] **Step 7: Lint and commit** + +```bash +cargo fmt --all +cargo clippy -p stint-core --all-targets -- -D warnings +git add crates/stint-core/src/ffi.rs crates/stint-core/src/lib.rs \ + crates/stint-core/tests/ffi_envelope.rs crates/stint-core/tests/ffi_panic_safety.rs +git commit -m "$(cat <<'EOF' +feat(core): FFI envelope + panic safety scaffolding for Swift bridge + +Adds stint_core::ffi with a Result-shaped JSON envelope helper, a +catch_unwind-wrapped invocation pattern, and stint_free_string for +Swift-side memory ownership. Stable error code contract (1 invariant, +2 not-found, 3 conflict, 4 serialization, 99 internal, -1 panic). + +Verbs land in a follow-up task; this commit only proves the envelope +shape and panic-recovery path. +EOF +)" +``` + +--- + +## Task A3: Rust FFI — 8 verb wrappers + +**Goal:** Expose all 8 verbs as `extern "C"` functions. Each takes a JSON parameter string, returns 0, and writes an envelope JSON into `*out_json`. + +**Files:** +- Modify: `crates/stint-core/src/ffi.rs` +- Create: `crates/stint-core/tests/ffi_verbs.rs` + +- [ ] **Step 1: Write failing tests for all 8 verbs** + +Create `crates/stint-core/tests/ffi_verbs.rs`: + +```rust +//! Integration tests for the 8 extern "C" verb wrappers. +//! +//! Each test sets up a tempdir store, calls the FFI fn with JSON params, +//! and asserts the envelope shape. + +mod common; + +use serde_json::{json, Value}; +use std::ffi::{c_char, CStr, CString}; +use std::ptr; + +fn call_verb(f: F) -> Value +where + F: FnOnce(*mut *mut c_char), +{ + let mut out: *mut c_char = ptr::null_mut(); + f(&mut out); + assert!(!out.is_null()); + let s = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + let v: Value = serde_json::from_str(&s).unwrap(); + unsafe { stint_core::ffi::stint_free_string(out) }; + v +} + +fn cstr(s: &str) -> CString { + CString::new(s).unwrap() +} + +#[test] +fn ffi_start_happy_path() { + let _setup = common::setup(); + let params = cstr(r#"{"description":"writing tests","source":"ffi-test"}"#); + let env = call_verb(|out| unsafe { + stint_core::ffi::stint_verb_start(params.as_ptr(), out); + }); + assert!(env["ok"].is_object(), "envelope: {env}"); + assert_eq!(env["ok"]["description"], "writing tests"); + assert_eq!(env["ok"]["source"], "ffi-test"); +} + +#[test] +fn ffi_start_invariant_already_running() { + let _setup = common::setup(); + let params = cstr(r#"{"description":"first","source":"ffi-test"}"#); + let _ = call_verb(|out| unsafe { stint_core::ffi::stint_verb_start(params.as_ptr(), out) }); + let env = call_verb(|out| unsafe { stint_core::ffi::stint_verb_start(params.as_ptr(), out) }); + assert_eq!(env["err"]["code"], 1, "envelope: {env}"); +} + +#[test] +fn ffi_current_when_running() { + let _setup = common::setup(); + let params = cstr(r#"{"description":"x","source":"ffi-test"}"#); + let _ = call_verb(|out| unsafe { stint_core::ffi::stint_verb_start(params.as_ptr(), out) }); + let env = call_verb(|out| unsafe { stint_core::ffi::stint_verb_current(out) }); + assert_eq!(env["ok"]["description"], "x"); +} + +#[test] +fn ffi_current_when_no_timer() { + let _setup = common::setup(); + let env = call_verb(|out| unsafe { stint_core::ffi::stint_verb_current(out) }); + assert!(env["ok"].is_null(), "envelope: {env}"); +} + +#[test] +fn ffi_stop_after_start() { + let _setup = common::setup(); + let params = cstr(r#"{"description":"y","source":"ffi-test"}"#); + let _ = call_verb(|out| unsafe { stint_core::ffi::stint_verb_start(params.as_ptr(), out) }); + let env = call_verb(|out| unsafe { stint_core::ffi::stint_verb_stop(out) }); + assert!(env["ok"]["end_at"].is_string()); +} + +#[test] +fn ffi_list_entries_empty() { + let _setup = common::setup(); + let filter = cstr("{}"); + let env = call_verb(|out| unsafe { + stint_core::ffi::stint_verb_list_entries(filter.as_ptr(), out) + }); + assert_eq!(env["ok"].as_array().unwrap().len(), 0); +} + +#[test] +fn ffi_list_projects_empty() { + let _setup = common::setup(); + let env = call_verb(|out| unsafe { stint_core::ffi::stint_verb_list_projects(out) }); + assert!(env["ok"].is_array(), "envelope: {env}"); +} + +#[test] +fn ffi_list_tasks_empty() { + let _setup = common::setup(); + let filter = cstr("{}"); + let env = call_verb(|out| unsafe { + stint_core::ffi::stint_verb_list_tasks(filter.as_ptr(), out) + }); + assert!(env["ok"].is_array(), "envelope: {env}"); +} + +#[test] +fn ffi_update_entry_not_found() { + let _setup = common::setup(); + let params = cstr(r#"{"local_uuid":"does-not-exist","patch":{}}"#); + let env = call_verb(|out| unsafe { + stint_core::ffi::stint_verb_update_entry(params.as_ptr(), out) + }); + assert_eq!(env["err"]["code"], 2); +} + +#[test] +fn ffi_delete_entry_not_found() { + let _setup = common::setup(); + let params = cstr(r#"{"local_uuid":"does-not-exist"}"#); + let env = call_verb(|out| unsafe { + stint_core::ffi::stint_verb_delete_entry(params.as_ptr(), out) + }); + assert_eq!(env["err"]["code"], 2); +} + +#[test] +fn ffi_malformed_json_returns_serialization_error() { + let _setup = common::setup(); + let params = cstr("not json"); + let env = call_verb(|out| unsafe { + stint_core::ffi::stint_verb_start(params.as_ptr(), out) + }); + assert_eq!(env["err"]["code"], 4); +} +``` + +The `mod common` line at the top reuses `crates/stint-core/tests/common/mod.rs` — already in the repo (sets up a tempdir Store and points STINT_HOME at it). + +- [ ] **Step 2: Run tests to confirm failure** + +```bash +cargo test -p stint-core --test ffi_verbs 2>&1 | tail -5 +``` + +Expected: compile error — verb functions don't exist. + +- [ ] **Step 3: Implement the 8 verb wrappers** + +Append to `crates/stint-core/src/ffi.rs`: + +```rust +use crate::{verbs, store::Store, Result}; +use std::ffi::CStr; + +/// Open the user-default store. Verbs that need it call this; on failure +/// (e.g., missing DB), they surface an Internal error envelope. +fn open_store() -> Result { + let path = crate::paths::default_db_path()?; + Store::open(&path) +} + +unsafe fn parse_params<'a, T: serde::de::DeserializeOwned>(ptr: *const c_char) -> Result { + if ptr.is_null() { + return Err(Error::Serialization("null params".into())); + } + let cstr = unsafe { CStr::from_ptr(ptr) }; + let s = cstr.to_str().map_err(|e| Error::Serialization(e.to_string()))?; + serde_json::from_str(s).map_err(|e| Error::Serialization(e.to_string())) +} + +// ---- start ---- + +#[no_mangle] +pub unsafe extern "C" fn stint_verb_start( + params_json: *const c_char, + out_json: *mut *mut c_char, +) -> i32 { + ffi_body(out_json, || { + let params: verbs::StartParams = parse_params(params_json)?; + let store = open_store()?; + verbs::start(&store, params) + }); + 0 +} + +// ---- stop ---- + +#[no_mangle] +pub unsafe extern "C" fn stint_verb_stop(out_json: *mut *mut c_char) -> i32 { + ffi_body(out_json, || { + let store = open_store()?; + verbs::stop(&store) + }); + 0 +} + +// ---- current ---- + +#[no_mangle] +pub unsafe extern "C" fn stint_verb_current(out_json: *mut *mut c_char) -> i32 { + ffi_body(out_json, || { + let store = open_store()?; + verbs::current(&store) // returns Option + }); + 0 +} + +// ---- list_entries ---- + +#[no_mangle] +pub unsafe extern "C" fn stint_verb_list_entries( + filter_json: *const c_char, + out_json: *mut *mut c_char, +) -> i32 { + ffi_body(out_json, || { + let filter: verbs::EntryFilter = parse_params(filter_json)?; + let store = open_store()?; + verbs::list_entries(&store, filter) + }); + 0 +} + +// ---- list_projects ---- + +#[no_mangle] +pub unsafe extern "C" fn stint_verb_list_projects(out_json: *mut *mut c_char) -> i32 { + ffi_body(out_json, || { + let store = open_store()?; + verbs::list_projects(&store) + }); + 0 +} + +// ---- list_tasks ---- + +#[derive(serde::Deserialize)] +struct ListTasksParams { + #[serde(default)] + project_id: Option, +} + +#[no_mangle] +pub unsafe extern "C" fn stint_verb_list_tasks( + params_json: *const c_char, + out_json: *mut *mut c_char, +) -> i32 { + ffi_body(out_json, || { + let p: ListTasksParams = parse_params(params_json)?; + let store = open_store()?; + verbs::list_tasks(&store, p.project_id.as_deref()) + }); + 0 +} + +// ---- update_entry ---- + +#[derive(serde::Deserialize)] +struct UpdateEntryParams { + local_uuid: String, + patch: verbs::EntryPatch, +} + +#[no_mangle] +pub unsafe extern "C" fn stint_verb_update_entry( + params_json: *const c_char, + out_json: *mut *mut c_char, +) -> i32 { + ffi_body(out_json, || { + let p: UpdateEntryParams = parse_params(params_json)?; + let store = open_store()?; + verbs::update_entry(&store, &p.local_uuid, p.patch) + }); + 0 +} + +// ---- delete_entry ---- + +#[derive(serde::Deserialize)] +struct DeleteEntryParams { + local_uuid: String, +} + +#[no_mangle] +pub unsafe extern "C" fn stint_verb_delete_entry( + params_json: *const c_char, + out_json: *mut *mut c_char, +) -> i32 { + ffi_body(out_json, || { + let p: DeleteEntryParams = parse_params(params_json)?; + let store = open_store()?; + verbs::delete_entry(&store, &p.local_uuid)?; + Ok::<_, Error>(serde_json::json!({})) + }); + 0 +} +``` + +Note that `verbs::list_tasks` currently requires a `project_id` — see Task A5 which extends it to accept `Option<&str>`. For now, write this with the new signature; Task A5 lands the trait change. + +- [ ] **Step 4: Extend `verbs::list_tasks` to accept Option<&str>** (Task A5 inlined here) + +Modify `crates/stint-core/src/verbs/list_tasks.rs` so the signature becomes: + +```rust +pub fn list_tasks(store: &Store, project_id: Option<&str>) -> Result> { + match project_id { + Some(id) => store.reference.list_tasks_for_project(id), + None => store.reference.list_all_tasks(), + } + .map(|rows| rows.into_iter().map(TaskView::from).collect()) +} +``` + +If `Store::reference::list_all_tasks` doesn't exist yet, add it in `crates/stint-core/src/store/reference.rs` — it's a `SELECT * FROM tasks WHERE done = 0` (no WHERE project_id clause). + +Update all call sites: +- `crates/stint-cli/src/cmd/list_tasks` (or wherever `verbs::list_tasks` is called) — wrap existing `project_id` in `Some(...)`. +- `crates/stint-app/src/commands/projects.rs` — same. +- `crates/stint-app/src/http/handlers.rs` — same; HTTP handler still requires `project_id` (existing contract); pass `Some(p.project_id.as_deref().unwrap_or(""))` or refactor to allow None query-param. Decision: HTTP keeps required `project_id` (existing API contract); only FFI gets the None-friendly path. +- MCP server — same as HTTP, keep required. + +- [ ] **Step 5: Run all tests** + +```bash +cargo test -p stint-core --test ffi_verbs 2>&1 | tail -20 +cargo test -p stint-core 2>&1 | tail -10 +``` + +Expected: all 11 ffi_verbs tests pass + all existing tests still green. + +- [ ] **Step 6: Lint + commit** + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +git add -A +git commit -m "$(cat <<'EOF' +feat(core): extern "C" verb wrappers for Swift bridge + +Adds the 8 verb FFI entry points: start, stop, current, list_entries, +list_projects, list_tasks, update_entry, delete_entry. Each accepts a +JSON param string (or no params for the 0-arg verbs) and writes a +{ok: T} | {err: {code, message}} envelope into out_json. Caller frees +via stint_free_string. + +Also extends verbs::list_tasks to accept Option<&str> for project_id so +the FFI can list across all projects — HTTP and MCP keep the required +project_id semantics. + +Tests cover happy paths, the already-running invariant, current-when-no- +timer, not-found update/delete, and malformed-JSON serialization errors. +EOF +)" +``` + +--- + +## Task A4: Rust FFI — settings get/set/clear + log + focus_id + +**Goal:** Small additional FFI surface for the Focus filter feature and for Swift to log into Rust's tracing subscriber. + +**Files:** +- Modify: `crates/stint-core/src/ffi.rs` +- Create: `crates/stint-core/tests/ffi_settings.rs` + +- [ ] **Step 1: Write failing tests** + +Create `crates/stint-core/tests/ffi_settings.rs`: + +```rust +mod common; + +use std::ffi::{c_char, CStr, CString}; +use std::ptr; + +#[test] +fn settings_set_get_clear_round_trip() { + let _setup = common::setup(); + let key = CString::new("focus.default_project").unwrap(); + let val = CString::new("focus-uuid-abc\tproject-uuid-xyz").unwrap(); + let rc = unsafe { stint_core::ffi::stint_settings_set(key.as_ptr(), val.as_ptr()) }; + assert_eq!(rc, 0); + + let mut out: *mut c_char = ptr::null_mut(); + let rc = unsafe { stint_core::ffi::stint_settings_get(key.as_ptr(), &mut out) }; + assert_eq!(rc, 0); + assert!(!out.is_null()); + let got = unsafe { CStr::from_ptr(out).to_str().unwrap().to_owned() }; + assert_eq!(got, "focus-uuid-abc\tproject-uuid-xyz"); + unsafe { stint_core::ffi::stint_free_string(out) }; + + let rc = unsafe { stint_core::ffi::stint_settings_clear(key.as_ptr()) }; + assert_eq!(rc, 0); + + let mut out: *mut c_char = ptr::null_mut(); + let rc = unsafe { stint_core::ffi::stint_settings_get(key.as_ptr(), &mut out) }; + assert_eq!(rc, 0); + assert!(out.is_null(), "cleared key must return null pointer"); +} + +#[test] +fn log_warn_does_not_panic() { + let msg = CString::new("hello from swift").unwrap(); + unsafe { stint_core::ffi::stint_log_warn(msg.as_ptr()) }; + // No assertion — just that it doesn't crash. tracing subscriber is set + // up by stint-app at runtime; in tests it's no-op. +} + +#[test] +fn current_focus_id_returns_null_in_tests() { + let mut out: *mut c_char = ptr::null_mut(); + let rc = unsafe { stint_core::ffi::stint_current_focus_id(&mut out) }; + assert_eq!(rc, 0); + // In tests the dlsym lookup returns null (Swift framework isn't loaded). + assert!(out.is_null()); +} +``` + +- [ ] **Step 2: Confirm failure, then implement** + +```bash +cargo test -p stint-core --test ffi_settings 2>&1 | tail -5 +``` + +Expected: compile error. + +Append to `crates/stint-core/src/ffi.rs`: + +```rust +// ---- settings ---- + +#[no_mangle] +pub unsafe extern "C" fn stint_settings_set(key: *const c_char, value: *const c_char) -> i32 { + if key.is_null() || value.is_null() { + return -2; + } + let result = panic::catch_unwind(|| -> Result<()> { + let key = CStr::from_ptr(key).to_str().map_err(|e| Error::Serialization(e.to_string()))?; + let value = CStr::from_ptr(value).to_str().map_err(|e| Error::Serialization(e.to_string()))?; + let store = open_store()?; + store.settings_set(key, value) + }); + match result { + Ok(Ok(())) => 0, + _ => 1, + } +} + +#[no_mangle] +pub unsafe extern "C" fn stint_settings_get(key: *const c_char, out_json: *mut *mut c_char) -> i32 { + if key.is_null() || out_json.is_null() { + return -2; + } + unsafe { *out_json = ptr::null_mut() }; + let result = panic::catch_unwind(|| -> Result> { + let key = CStr::from_ptr(key).to_str().map_err(|e| Error::Serialization(e.to_string()))?; + let store = open_store()?; + store.settings_get(key) + }); + match result { + Ok(Ok(Some(v))) => { + if let Ok(c) = CString::new(v) { + unsafe { *out_json = c.into_raw() }; + } + 0 + } + Ok(Ok(None)) => 0, + _ => 1, + } +} + +#[no_mangle] +pub unsafe extern "C" fn stint_settings_clear(key: *const c_char) -> i32 { + if key.is_null() { + return -2; + } + let result = panic::catch_unwind(|| -> Result<()> { + let key = CStr::from_ptr(key).to_str().map_err(|e| Error::Serialization(e.to_string()))?; + let store = open_store()?; + store.settings_clear(key) + }); + match result { + Ok(Ok(())) => 0, + _ => 1, + } +} + +// ---- log forwarder ---- + +#[no_mangle] +pub unsafe extern "C" fn stint_log_warn(msg: *const c_char) { + if msg.is_null() { + return; + } + if let Ok(s) = CStr::from_ptr(msg).to_str() { + tracing::warn!(target: "stint_intents", "{}", s); + } +} + +// ---- focus_id (dlsym'd from Swift; stub when framework absent) ---- + +type FocusIdFn = unsafe extern "C" fn(*mut *mut c_char) -> i32; + +static FOCUS_ID_SYMBOL: std::sync::OnceLock> = std::sync::OnceLock::new(); + +unsafe fn lookup_focus_id() -> Option { + *FOCUS_ID_SYMBOL.get_or_init(|| { + let handle = libc::dlopen(std::ptr::null(), libc::RTLD_NOW); + if handle.is_null() { + return None; + } + let name = std::ffi::CString::new("stint_current_focus_id_swift").unwrap(); + let sym = libc::dlsym(handle, name.as_ptr()); + if sym.is_null() { + None + } else { + Some(std::mem::transmute::<*mut libc::c_void, FocusIdFn>(sym)) + } + }) +} + +#[no_mangle] +pub unsafe extern "C" fn stint_current_focus_id(out_json: *mut *mut c_char) -> i32 { + if out_json.is_null() { + return -2; + } + unsafe { *out_json = ptr::null_mut() }; + if let Some(f) = lookup_focus_id() { + f(out_json) + } else { + 0 // framework not loaded → return null = no current focus + } +} +``` + +Add to `crates/stint-core/Cargo.toml` `[dependencies]`: + +```toml +libc = "0.2" +``` + +(Skip if already present — check the existing file.) + +Also: `Store::settings_set/get/clear` may need to be exposed publicly if they're not already. Check `crates/stint-core/src/store/settings.rs` (or wherever settings live) and ensure those three methods are `pub`. If not, make them `pub` and add unit tests if any are missing. + +- [ ] **Step 3: Run tests** + +```bash +cargo test -p stint-core --test ffi_settings 2>&1 | tail -10 +``` + +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +git add -A +git commit -m "$(cat <<'EOF' +feat(core): FFI surface for settings, log forwarder, and focus_id dlsym + +Adds three more FFI surfaces beyond the verb wrappers: + +- stint_settings_set/get/clear: opaque key/value passthrough so Swift + Focus filters can persist their (focus_id, project_id) selection. +- stint_log_warn: lets Swift route logs into stint's tracing subscriber + via the existing "stint_intents" target. +- stint_current_focus_id: dlsym-looks up a Swift-exported helper. When + the framework isn't loaded (CLI binary), returns null = no focus, + which the start-verb fallback treats as "no default". + +All three are catch_unwind-wrapped. Memory ownership is the same as the +verb wrappers: caller frees out_json via stint_free_string. +EOF +)" +``` + +--- + +## Task A6: Focus default applied in `verbs::start` + +**Goal:** Implement the focus-id-reconciled fallback in `verbs::start` so any surface that calls start without a `project_id` picks up the Focus default — but only if the stored focus_id still matches the current macOS focus. + +**Files:** +- Modify: `crates/stint-core/src/verbs/start.rs` +- Create: `crates/stint-core/src/focus.rs` (small helper) +- Modify: `crates/stint-core/src/lib.rs` (register module) +- Modify: `crates/stint-core/tests/start.rs` (or wherever start tests live) + +- [ ] **Step 1: Write failing tests** + +Find the existing test file for `verbs::start` (`crates/stint-core/tests/start.rs` or under `tests/`). Add: + +```rust +#[test] +fn start_picks_up_focus_default_when_project_missing() { + let setup = common::setup(); + let store = setup.store(); + + // Seed a project so the default points somewhere valid. + common::seed_projects(&store, &[("proj-uuid-1", "Acme")]); + + // Simulate the Focus filter writing its tuple. + // Note: in the real flow, Swift writes this via stint_settings_set. + store + .settings_set("focus.default_project", "fake-focus-id\tproj-uuid-1") + .unwrap(); + + // Inject the "current focus id" for tests via STINT_TEST_FOCUS_ID env var. + std::env::set_var("STINT_TEST_FOCUS_ID", "fake-focus-id"); + + let view = verbs::start( + &store, + verbs::StartParams { + description: "no project given".into(), + project_id: None, + task_id: None, + billable: false, + start_at: None, + source: "test".into(), + }, + ) + .unwrap(); + + assert_eq!(view.project_id.as_deref(), Some("proj-uuid-1")); + + std::env::remove_var("STINT_TEST_FOCUS_ID"); +} + +#[test] +fn start_ignores_focus_default_when_focus_id_mismatches() { + let setup = common::setup(); + let store = setup.store(); + + store + .settings_set("focus.default_project", "fake-focus-id\tproj-uuid-1") + .unwrap(); + + std::env::set_var("STINT_TEST_FOCUS_ID", "different-focus-id"); + + let view = verbs::start( + &store, + verbs::StartParams { + description: "no project given".into(), + project_id: None, + task_id: None, + billable: false, + start_at: None, + source: "test".into(), + }, + ) + .unwrap(); + + assert_eq!(view.project_id, None); + std::env::remove_var("STINT_TEST_FOCUS_ID"); +} + +#[test] +fn start_explicit_project_overrides_focus_default() { + let setup = common::setup(); + let store = setup.store(); + + common::seed_projects( + &store, + &[("proj-uuid-1", "Acme"), ("proj-uuid-2", "Other")], + ); + + store + .settings_set("focus.default_project", "fake-focus-id\tproj-uuid-1") + .unwrap(); + std::env::set_var("STINT_TEST_FOCUS_ID", "fake-focus-id"); + + let view = verbs::start( + &store, + verbs::StartParams { + description: "explicit project".into(), + project_id: Some("proj-uuid-2".into()), + task_id: None, + billable: false, + start_at: None, + source: "test".into(), + }, + ) + .unwrap(); + + assert_eq!(view.project_id.as_deref(), Some("proj-uuid-2")); + std::env::remove_var("STINT_TEST_FOCUS_ID"); +} +``` + +- [ ] **Step 2: Confirm failure** + +```bash +cargo test -p stint-core start_picks_up_focus_default 2>&1 | tail -5 +``` + +Expected: failures (tests pass `None` and expect Some). + +- [ ] **Step 3: Add focus helper** + +Create `crates/stint-core/src/focus.rs`: + +```rust +//! Looks up the currently active macOS Focus identifier. +//! +//! In production (Stint.app loaded with StintIntents.framework), this dlsym's +//! into a Swift helper. In tests and the CLI binary, it reads STINT_TEST_FOCUS_ID +//! from the environment so the start-verb fallback can be exercised. + +use std::ffi::{c_char, CStr}; + +pub fn current_id() -> Option { + // Test escape hatch — always check this first, even in release builds, so + // CLI integration tests can stand in for the framework. + if let Ok(v) = std::env::var("STINT_TEST_FOCUS_ID") { + if !v.is_empty() { + return Some(v); + } + } + + let mut out: *mut c_char = std::ptr::null_mut(); + let rc = unsafe { crate::ffi::stint_current_focus_id(&mut out) }; + if rc != 0 || out.is_null() { + return None; + } + let s = unsafe { CStr::from_ptr(out).to_str().ok()?.to_owned() }; + unsafe { crate::ffi::stint_free_string(out) }; + if s.is_empty() { + None + } else { + Some(s) + } +} +``` + +Register in `crates/stint-core/src/lib.rs`: + +```rust +pub mod focus; +``` + +- [ ] **Step 4: Wire the fallback into `verbs::start`** + +Modify `crates/stint-core/src/verbs/start.rs`. Locate the early body where `params.project_id` is read, and insert the fallback before any use: + +```rust +pub fn start(store: &Store, params: StartParams) -> Result { + let project_id = params.project_id.clone().or_else(|| { + let raw = store.settings_get("focus.default_project").ok().flatten()?; + let (stored_focus, project_id) = raw.split_once('\t')?; + let current = crate::focus::current_id()?; + if current == stored_focus { + Some(project_id.to_string()) + } else { + None + } + }); + + let params = StartParams { project_id, ..params }; + // ... existing implementation continues with the (possibly defaulted) params +} +``` + +(Exact integration depends on the existing structure of `start.rs` — read it first and adapt.) + +- [ ] **Step 5: Run tests** + +```bash +cargo test -p stint-core start_picks_up_focus_default start_ignores_focus_default_when start_explicit_project_overrides 2>&1 | tail -15 +``` + +Expected: 3 tests pass. + +- [ ] **Step 6: Commit** + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +git add -A +git commit -m "$(cat <<'EOF' +feat(core): focus-default fallback in verbs::start + +When start() is called with no project_id, look up the focus default +written by Swift's ProjectFocusFilter (stored as "\t") +and apply it only if the stored focus_id matches the currently active +macOS focus. This prevents a stale default from leaking after the user +switches focus modes. + +STINT_TEST_FOCUS_ID env var is the test escape hatch — production reads +the focus id via stint_current_focus_id (dlsym'd into Swift). +EOF +)" +``` + +--- + +## Task B1: URL scheme additions — OpenProject, OpenTask + +**Goal:** Extend `stint://` URL parser to handle `stint://project/` and `stint://task/`, route them through the Tauri deep-link handler, and navigate the SolidJS UI to the filtered Today view. + +**Files:** +- Modify: `crates/stint-core/src/url_scheme.rs` +- Modify: `crates/stint-core/src/url_scheme.rs` tests (inline `#[cfg(test)] mod tests`) +- Modify: `crates/stint-app/src/lib.rs` (Tauri deep-link handler) +- Modify: `ui/src/routes/Today.tsx` (or wherever the Today view reads query params) + +- [ ] **Step 1: Add failing URL parse tests** + +Locate the `#[cfg(test)] mod tests` block in `crates/stint-core/src/url_scheme.rs`. Append: + +```rust + #[test] + fn parse_open_project() { + let action = parse("stint://project/proj-uuid-1").unwrap(); + assert!(matches!(action, Action::OpenProject { ref project_id } if project_id == "proj-uuid-1")); + } + + #[test] + fn parse_open_task() { + let action = parse("stint://task/task-uuid-1").unwrap(); + assert!(matches!(action, Action::OpenTask { ref task_id } if task_id == "task-uuid-1")); + } + + #[test] + fn parse_open_project_missing_id_errors() { + assert!(parse("stint://project").is_err()); + assert!(parse("stint://project/").is_err()); + } +``` + +- [ ] **Step 2: Extend `Action` and `parse`** + +In the same file, locate the `Action` enum and add: + +```rust +pub enum Action { + // existing variants + Start { ... }, + Stop, + OpenEntry { local_uuid: String }, + Current, + // new: + OpenProject { project_id: String }, + OpenTask { task_id: String }, +} +``` + +In the `match head` block, add: + +```rust +"project" => { + let project_id = segments + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| Error::Invariant("project requires id".into()))? + .to_string(); + Ok(Action::OpenProject { project_id }) +} +"task" => { + let task_id = segments + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| Error::Invariant("task requires id".into()))? + .to_string(); + Ok(Action::OpenTask { task_id }) +} +``` + +- [ ] **Step 3: Run url_scheme tests** + +```bash +cargo test -p stint-core url_scheme 2>&1 | tail -10 +``` + +Expected: new tests pass, existing tests still green. + +- [ ] **Step 4: Route the new actions in the Tauri deep-link handler** + +Open `crates/stint-app/src/lib.rs` (or wherever the deep-link handler lives — search for `tauri_plugin_deep_link` or `parse_url`). Locate the `match action` block and add: + +```rust +Action::OpenProject { project_id } => { + let _ = app.emit("navigate", serde_json::json!({ + "route": format!("/today?project={}", project_id) + })); + show_main_window(app); +} +Action::OpenTask { task_id } => { + // Resolve task → project_id via verbs::list_tasks so we can build the URL. + let store = open_store_or_warn(app); + if let Some(store) = store { + if let Ok(all_tasks) = verbs::list_tasks(&store, None) { + if let Some(t) = all_tasks.iter().find(|t| t.solidtime_id == task_id) { + let _ = app.emit("navigate", serde_json::json!({ + "route": format!("/today?project={}&task={}", t.project_id, task_id) + })); + show_main_window(app); + return Ok(()); + } + } + } + // Fallback: open Today view without filter. + let _ = app.emit("navigate", serde_json::json!({ "route": "/today" })); + show_main_window(app); +} +``` + +The exact `show_main_window` helper and `open_store_or_warn` patterns already exist in the file — match the style. + +- [ ] **Step 5: Handle the `navigate` event in the UI** + +Find the `App.tsx` or root component that listens for Tauri events. There's likely already a listener — confirm `navigate` is one of them. If not, add: + +```tsx +// ui/src/App.tsx (or wherever event listeners are set up) +import { listen } from "@tauri-apps/api/event"; +import { useNavigate } from "@solidjs/router"; + +const navigate = useNavigate(); + +onMount(async () => { + const unlisten = await listen<{ route: string }>("navigate", (e) => { + navigate(e.payload.route); + }); + onCleanup(() => unlisten()); +}); +``` + +If the listener is already there for other purposes, simply confirm `/today?project=...` resolves correctly in the SolidJS router (the Today route may need to read `searchParams` and apply the filter). + +In `ui/src/routes/Today.tsx`, add: + +```tsx +import { useSearchParams } from "@solidjs/router"; + +const [searchParams] = useSearchParams(); +const projectFilter = () => searchParams.project; +const taskFilter = () => searchParams.task; + +// Use these in the entries query / filter UI to pre-select the project/task +``` + +- [ ] **Step 6: Typecheck + commit** + +```bash +pnpm typecheck +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +git add -A +git commit -m "$(cat <<'EOF' +feat(core): stint:// URL routes for projects and tasks + +Extends url_scheme::parse to recognize stint://project/ and +stint://task/. The Tauri deep-link handler emits a navigate event +that the SolidJS router consumes to land on /today filtered to the +chosen project (and task, if applicable). + +Spotlight result taps for project/task CSSearchableItems use these +routes in Phase 6b. +EOF +)" +``` + +--- + +## Task B2: Pull worker → indexer notify hook + +**Goal:** When the pull worker completes a successful Solidtime pull (projects, tasks updated), notify the Spotlight indexer to refresh the affected slice. This is the Rust→Swift FFI for non-verb-driven mutations. + +**Files:** +- Modify: `crates/stint-core/src/ffi.rs` (add `notify_indexer`) +- Modify: `crates/stint-app/src/pull_worker.rs` + +- [ ] **Step 1: Add `notify_indexer` to ffi.rs** + +Append: + +```rust +// ---- indexer notify (Rust → Swift via dlsym) ---- + +#[repr(i32)] +#[derive(Debug, Clone, Copy)] +pub enum IndexerKind { + EntryStarted = 1, + EntryStopped = 2, + EntryUpdated = 3, + EntryDeleted = 4, + ProjectsReplaced = 5, + TasksReplaced = 6, +} + +type IndexerNotifyFn = unsafe extern "C" fn(i32, *const c_char); +static INDEXER_NOTIFY_SYMBOL: std::sync::OnceLock> = std::sync::OnceLock::new(); + +unsafe fn lookup_indexer_notify() -> Option { + *INDEXER_NOTIFY_SYMBOL.get_or_init(|| { + let handle = libc::dlopen(std::ptr::null(), libc::RTLD_NOW); + if handle.is_null() { + return None; + } + let name = std::ffi::CString::new("swift_indexer_notify").unwrap(); + let sym = libc::dlsym(handle, name.as_ptr()); + if sym.is_null() { + None + } else { + Some(std::mem::transmute::<*mut libc::c_void, IndexerNotifyFn>(sym)) + } + }) +} + +/// Call from Rust verb call sites and pull_worker. No-op when the Swift +/// framework isn't loaded (CLI binary, headless tests). +pub fn notify_indexer(kind: IndexerKind, payload_json: &str) { + let Some(f) = (unsafe { lookup_indexer_notify() }) else { + return; + }; + let Ok(c) = CString::new(payload_json) else { + return; + }; + unsafe { f(kind as i32, c.as_ptr()) }; +} +``` + +- [ ] **Step 2: Wire into pull worker** + +Read `crates/stint-app/src/pull_worker.rs`. Locate the success path (after projects + tasks are written to the store). Add: + +```rust +use stint_core::ffi::{notify_indexer, IndexerKind}; + +// After successful project pull: +if let Ok(projects) = verbs::list_projects(&store) { + if let Ok(payload) = serde_json::to_string(&projects) { + notify_indexer(IndexerKind::ProjectsReplaced, &payload); + } +} + +// After successful task pull: +if let Ok(tasks) = verbs::list_tasks(&store, None) { + if let Ok(payload) = serde_json::to_string(&tasks) { + notify_indexer(IndexerKind::TasksReplaced, &payload); + } +} +``` + +- [ ] **Step 3: Wire into the verb mutation sites** + +In `crates/stint-core/src/verbs/start.rs`, after the store write succeeds (right before returning), add: + +```rust +if let Ok(payload) = serde_json::to_string(&view) { + crate::ffi::notify_indexer(crate::ffi::IndexerKind::EntryStarted, &payload); +} +``` + +Repeat for `stop.rs` (EntryStopped), `update_entry.rs` (EntryUpdated), `delete_entry.rs` (EntryDeleted — payload is the local_uuid as a string). + +For `delete_entry`, payload is JSON `{"local_uuid": "..."}`. + +- [ ] **Step 4: Build to verify no link errors** + +```bash +cargo build --workspace +cargo test --workspace -- --test-threads=1 2>&1 | tail -10 +``` + +Expected: clean build, all existing tests still pass (the `notify_indexer` call is a no-op in tests because Swift isn't loaded). + +- [ ] **Step 5: Commit** + +```bash +cargo fmt --all +git add -A +git commit -m "$(cat <<'EOF' +feat(core): notify_indexer hook on verb mutations + pull completion + +Adds a Rust→Swift FFI for incremental Spotlight index updates. The +hook dlsym-looks up swift_indexer_notify and no-ops when absent so +stint-cli (which never loads the Swift framework) compiles and runs +unchanged. + +Wired into: verbs::start/stop/update_entry/delete_entry (per-entry +deltas) and stint-app's pull_worker (replace-all projects/tasks after +a successful Solidtime down-sync). +EOF +)" +``` + +--- + +## Task C1: C header for Swift bridging + +**Goal:** Hand-written C header that the Swift Package's bridging header imports so Swift can call the Rust FFI symbols. + +**Files:** +- Create: `crates/stint-core/include/stint_core.h` + +- [ ] **Step 1: Write the header** + +Create `crates/stint-core/include/stint_core.h`: + +```c +// +// stint_core.h +// C ABI declarations for the StintIntents Swift framework. +// +// All functions return either 0 (success — see `out_json` for the JSON +// envelope `{ok:T}` or `{err:{code,message}}`) or -2 on null-pointer misuse. +// +// Memory ownership: all out_json strings are malloc'd by Rust and must be +// freed by the caller via `stint_free_string`. Passing NULL is safe. +// + +#ifndef STINT_CORE_H +#define STINT_CORE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ---- string lifecycle ---- +void stint_free_string(char *ptr); + +// ---- verbs ---- +int32_t stint_verb_start(const char *params_json, char **out_json); +int32_t stint_verb_stop(char **out_json); +int32_t stint_verb_current(char **out_json); +int32_t stint_verb_list_entries(const char *filter_json, char **out_json); +int32_t stint_verb_list_projects(char **out_json); +int32_t stint_verb_list_tasks(const char *params_json, char **out_json); +int32_t stint_verb_update_entry(const char *params_json, char **out_json); +int32_t stint_verb_delete_entry(const char *params_json, char **out_json); + +// ---- settings + log + focus ---- +int32_t stint_settings_set(const char *key, const char *value); +int32_t stint_settings_get(const char *key, char **out_json); +int32_t stint_settings_clear(const char *key); +void stint_log_warn(const char *msg); +int32_t stint_current_focus_id(char **out_json); + +#ifdef __cplusplus +} +#endif + +#endif // STINT_CORE_H +``` + +- [ ] **Step 2: Commit** + +```bash +git add crates/stint-core/include/stint_core.h +git commit -m "feat(core): C header for Swift bridging into Rust FFI" +``` + +--- + +## Task C2: Swift Package scaffold — Package.swift + Bridge.swift + +**Goal:** Replace the throwaway spike package with the real `Package.swift`. Wire up the Rust FFI declarations so Swift can call into Rust. + +**Files:** +- Create/overwrite: `crates/stint-app/swift/StintIntents/Package.swift` +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Bridge.swift` +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/include/stint_intents_bridge.h` +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/module.modulemap` + +- [ ] **Step 1: Final Package.swift** + +```swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "StintIntents", + platforms: [.macOS(.v13)], + products: [ + .library(name: "StintIntents", type: .dynamic, targets: ["StintIntents"]), + ], + targets: [ + .target( + name: "StintIntents", + path: "Sources/StintIntents", + exclude: ["Shortcuts/PhraseStrings.xcstrings"], // resource, declared below + resources: [ + .process("Shortcuts/PhraseStrings.xcstrings"), + ], + publicHeadersPath: "include", + cSettings: [ + .headerSearchPath("../../../../stint-core/include"), + ], + linkerSettings: [ + .linkedLibrary("stint_core"), // resolved at app-link time + .unsafeFlags(["-L../../../../target/release"]), + ] + ), + .testTarget( + name: "StintIntentsTests", + dependencies: ["StintIntents"], + path: "Tests/StintIntentsTests" + ), + .testTarget( + name: "StintIntentsIntegrationTests", + dependencies: ["StintIntents"], + path: "Tests/StintIntentsIntegrationTests" + ), + ] +) +``` + +The `linkerSettings.linkedLibrary("stint_core")` and `unsafeFlags(-L...)` are placeholders — `cargo build -p stint-core` produces a `libstint_core.dylib` (workspace target dir) that the framework will link against at app-bundle time. Adjust the path based on `target/debug` vs `target/release` and whether stint-core is built as cdylib vs rlib. Worst case, drop the `linkerSettings` here and have `crates/stint-app/build.rs` handle the link directly via `-rpath` flags on the final Tauri binary. + +- [ ] **Step 2: Bridging header** + +Create `Sources/StintIntents/include/stint_intents_bridge.h`: + +```c +#ifndef STINT_INTENTS_BRIDGE_H +#define STINT_INTENTS_BRIDGE_H + +#include "stint_core.h" + +#endif +``` + +Create `Sources/StintIntents/module.modulemap`: + +``` +module CStintCore { + header "include/stint_intents_bridge.h" + export * +} +``` + +- [ ] **Step 3: Bridge.swift — protocol + FFIBridge + StubBridge** + +Create `Sources/StintIntents/Bridge.swift`: + +```swift +import Foundation +import CStintCore + +// MARK: - Envelope decoding + +struct Envelope: Decodable { + let ok: T? + let err: EnvelopeErr? +} + +struct EnvelopeErr: Decodable { + let code: Int + let message: String +} + +// MARK: - Bridge protocol + +/// Abstracts the FFI surface so unit tests can inject a stub. +protocol Bridge { + func start(_ params: StartParams) throws -> EntryDTO + func stop() throws -> EntryDTO + func current() throws -> EntryDTO? + func listEntries(_ filter: EntryFilter) throws -> [EntryDTO] + func listProjects() throws -> [ProjectDTO] + func listTasks(projectId: String?) throws -> [TaskDTO] + func updateEntry(localUuid: String, patch: EntryPatch) throws -> EntryDTO + func deleteEntry(localUuid: String) throws + + func settingsSet(_ key: String, _ value: String) throws + func settingsGet(_ key: String) throws -> String? + func settingsClear(_ key: String) throws + + func currentFocusId() -> String? + func logWarn(_ msg: String) +} + +// MARK: - DTOs (match the Rust serde shapes in verbs/types.rs) + +struct StartParams: Encodable { + var description: String + var projectId: String? + var taskId: String? + var billable: Bool = false + var startAt: String? = nil // ISO 8601 UTC + var source: String = "intent" + + enum CodingKeys: String, CodingKey { + case description, source, billable + case projectId = "project_id" + case taskId = "task_id" + case startAt = "start_at" + } +} + +struct EntryFilter: Encodable { + var since: String? = nil + var until: String? = nil + var projectId: String? = nil + var limit: UInt32? = nil + + enum CodingKeys: String, CodingKey { + case since, until, limit + case projectId = "project_id" + } +} + +struct EntryPatch: Encodable { + var description: String? + // For nullable fields we use a sentinel because Swift can't express + // Option> directly. Encode as JSON null vs absent. + var projectId: ProjectIdPatch = .unchanged + var taskId: ProjectIdPatch = .unchanged // same 3-way semantics + var billable: Bool? + var startAt: String? + var endAt: EndAtPatch = .unchanged + + enum CodingKeys: String, CodingKey { + case description, billable + case projectId = "project_id" + case taskId = "task_id" + case startAt = "start_at" + case endAt = "end_at" + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + if let d = description { try c.encode(d, forKey: .description) } + if let b = billable { try c.encode(b, forKey: .billable) } + if let s = startAt { try c.encode(s, forKey: .startAt) } + switch projectId { + case .unchanged: break + case .clear: try c.encodeNil(forKey: .projectId) + case .set(let v): try c.encode(v, forKey: .projectId) + } + switch taskId { + case .unchanged: break + case .clear: try c.encodeNil(forKey: .taskId) + case .set(let v): try c.encode(v, forKey: .taskId) + } + switch endAt { + case .unchanged: break + case .clear: try c.encodeNil(forKey: .endAt) + case .set(let v): try c.encode(v, forKey: .endAt) + } + } +} + +enum ProjectIdPatch { case unchanged, clear, set(String) } +enum EndAtPatch { case unchanged, clear, set(String) } + +struct EntryDTO: Decodable { + let localUuid: String + let solidtimeId: String? + let description: String + let projectId: String? + let taskId: String? + let billable: Bool + let startAt: String + let endAt: String? + let source: String + + enum CodingKeys: String, CodingKey { + case description, billable, source + case localUuid = "local_uuid" + case solidtimeId = "solidtime_id" + case projectId = "project_id" + case taskId = "task_id" + case startAt = "start_at" + case endAt = "end_at" + } +} + +struct ProjectDTO: Decodable { + let solidtimeId: String + let name: String + let color: String? + let clientId: String? + let archived: Bool + + enum CodingKeys: String, CodingKey { + case name, color, archived + case solidtimeId = "solidtime_id" + case clientId = "client_id" + } +} + +struct TaskDTO: Decodable { + let solidtimeId: String + let projectId: String + let name: String + let done: Bool + + enum CodingKeys: String, CodingKey { + case name, done + case solidtimeId = "solidtime_id" + case projectId = "project_id" + } +} + +// MARK: - FFIBridge — production implementation + +final class FFIBridge: Bridge { + static let shared = FFIBridge() + + private let encoder: JSONEncoder = { + let e = JSONEncoder() + return e + }() + private let decoder: JSONDecoder = { + let d = JSONDecoder() + return d + }() + + private func callWithParams( + _ verb: (UnsafePointer?, UnsafeMutablePointer?>?) -> Int32, + _ params: P + ) throws -> T { + let json = try encoder.encode(params) + let cstr = json.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> [CChar] in + var buf = Array(raw.bindMemory(to: CChar.self)) + buf.append(0) + return buf + } + var out: UnsafeMutablePointer? + _ = cstr.withUnsafeBufferPointer { ptr in + verb(ptr.baseAddress, &out) + } + return try decodeEnvelope(out) + } + + private func callNoParams( + _ verb: (UnsafeMutablePointer?>?) -> Int32 + ) throws -> T { + var out: UnsafeMutablePointer? + _ = verb(&out) + return try decodeEnvelope(out) + } + + private func decodeEnvelope(_ ptr: UnsafeMutablePointer?) throws -> T { + guard let ptr = ptr else { throw BridgeError.internal("null envelope ptr") } + defer { stint_free_string(ptr) } + let data = Data(bytesNoCopy: ptr, count: strlen(ptr), deallocator: .none) + let env = try decoder.decode(Envelope.self, from: data) + if let e = env.err { + throw BridgeError.from(code: Int32(e.code), message: e.message) + } + guard let ok = env.ok else { + throw BridgeError.internal("envelope missing both ok and err") + } + return ok + } + + func start(_ params: StartParams) throws -> EntryDTO { + return try callWithParams(stint_verb_start, params) + } + + func stop() throws -> EntryDTO { + return try callNoParams(stint_verb_stop) + } + + func current() throws -> EntryDTO? { + var out: UnsafeMutablePointer? + _ = stint_verb_current(&out) + guard let ptr = out else { return nil } + defer { stint_free_string(ptr) } + let data = Data(bytesNoCopy: ptr, count: strlen(ptr), deallocator: .none) + let env = try decoder.decode(Envelope.self, from: data) + if let e = env.err { + throw BridgeError.from(code: Int32(e.code), message: e.message) + } + return env.ok ?? nil + } + + func listEntries(_ filter: EntryFilter) throws -> [EntryDTO] { + return try callWithParams(stint_verb_list_entries, filter) + } + + func listProjects() throws -> [ProjectDTO] { + return try callNoParams(stint_verb_list_projects) + } + + func listTasks(projectId: String?) throws -> [TaskDTO] { + struct P: Encodable { + let projectId: String? + enum CodingKeys: String, CodingKey { case projectId = "project_id" } + } + return try callWithParams(stint_verb_list_tasks, P(projectId: projectId)) + } + + func updateEntry(localUuid: String, patch: EntryPatch) throws -> EntryDTO { + struct P: Encodable { + let localUuid: String + let patch: EntryPatch + enum CodingKeys: String, CodingKey { + case patch + case localUuid = "local_uuid" + } + } + return try callWithParams(stint_verb_update_entry, P(localUuid: localUuid, patch: patch)) + } + + func deleteEntry(localUuid: String) throws { + struct P: Encodable { + let localUuid: String + enum CodingKeys: String, CodingKey { case localUuid = "local_uuid" } + } + let _: [String: String] = try callWithParams(stint_verb_delete_entry, P(localUuid: localUuid)) + } + + func settingsSet(_ key: String, _ value: String) throws { + let rc = key.withCString { k in value.withCString { v in stint_settings_set(k, v) } } + if rc != 0 { throw BridgeError.internal("settings_set rc=\(rc)") } + } + + func settingsGet(_ key: String) throws -> String? { + var out: UnsafeMutablePointer? + let rc = key.withCString { k in stint_settings_get(k, &out) } + if rc != 0 { throw BridgeError.internal("settings_get rc=\(rc)") } + guard let ptr = out else { return nil } + defer { stint_free_string(ptr) } + return String(cString: ptr) + } + + func settingsClear(_ key: String) throws { + let rc = key.withCString { k in stint_settings_clear(k) } + if rc != 0 { throw BridgeError.internal("settings_clear rc=\(rc)") } + } + + func currentFocusId() -> String? { + var out: UnsafeMutablePointer? + let rc = stint_current_focus_id(&out) + if rc != 0 { return nil } + guard let ptr = out else { return nil } + defer { stint_free_string(ptr) } + return String(cString: ptr) + } + + func logWarn(_ msg: String) { + msg.withCString { stint_log_warn($0) } + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/stint-app/swift/StintIntents/ +git commit -m "$(cat <<'EOF' +feat(swift): SPM scaffold + Bridge protocol + FFIBridge + +Final Package.swift declares the StintIntents dynamic library targeting +macOS 13+. Module map exposes the hand-written C header. + +Bridge.swift defines the Bridge protocol (so AppIntent unit tests can +inject a stub) and FFIBridge (the production implementation that calls +into stint-core's extern "C" surface). DTOs mirror the Rust verb shapes +in stint_core::verbs::types via Codable. + +The EntryPatch 3-way nullable semantics (unchanged / clear / set) are +modeled via custom Encodable that emits absent / null / value correctly. +EOF +)" +``` + +--- + +## Task C3: BridgeError + +**Goal:** `BridgeError` enum that maps envelope codes to typed Swift errors and conforms to App Intents' error vocabulary. + +**Files:** +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Errors/BridgeError.swift` + +- [ ] **Step 1: Write the file** + +```swift +import Foundation +import AppIntents + +enum BridgeError: LocalizedError { + case invariant(String) + case notFound(String) + case conflict(String) + case serialization(String) + case `internal`(String) + case panic(String) + + static func from(code: Int32, message: String) -> BridgeError { + switch code { + case 1: return .invariant(message) + case 2: return .notFound(message) + case 3: return .conflict(message) + case 4: return .serialization(message) + case -1: return .panic(message) + default: return .internal(message) + } + } + + var errorDescription: String? { + switch self { + case .invariant(let m), .notFound(let m): return m + case .conflict(_): return "That conflicts with an existing entry." + case .serialization(_): return "Couldn't read the request." + case .internal(_): return "Stint hit an internal error. Check the app." + case .panic(_): return "Stint encountered an unexpected error." + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add crates/stint-app/swift/StintIntents/Sources/StintIntents/Errors/BridgeError.swift +git commit -m "feat(swift): BridgeError mapping FFI codes to LocalizedError" +``` + +--- + +## Task D1-D3: Swift entities — Project / Task / Entry + their EntityQuery types + +**Goal:** Three `AppEntity` + `EntityQuery` pairs. Each entity is `IndexedEntity` so Spotlight gets it for free. + +**Files (one task each, three tasks total):** +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/ProjectEntity.swift` +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/ProjectQuery.swift` +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/TaskEntity.swift` +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/TaskQuery.swift` +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/EntryEntity.swift` +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/EntryQuery.swift` + +Each task is one entity+query pair. Pattern (showing ProjectEntity; TaskEntity and EntryEntity follow): + +**`ProjectEntity.swift`:** + +```swift +import AppIntents +import Foundation + +struct ProjectEntity: AppEntity, IndexedEntity { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project") + static var defaultQuery = ProjectQuery() + + let id: String + let name: String + let clientName: String? + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(name)", + subtitle: clientName.map { "Project · \($0)" } ?? "Project" + ) + } + + init(from dto: ProjectDTO) { + self.id = dto.solidtimeId + self.name = dto.name + self.clientName = nil // TODO: pull from Solidtime client cache when available + } +} +``` + +**`ProjectQuery.swift`:** + +```swift +import AppIntents +import Foundation + +struct ProjectQuery: EntityQuery { + var bridge: Bridge = FFIBridge.shared + + func entities(for identifiers: [ProjectEntity.ID]) async throws -> [ProjectEntity] { + let all = try bridge.listProjects().map(ProjectEntity.init(from:)) + return all.filter { identifiers.contains($0.id) } + } + + func suggestedEntities() async throws -> [ProjectEntity] { + try bridge.listProjects().filter { !$0.archived }.map(ProjectEntity.init(from:)) + } +} + +extension ProjectQuery: EntityStringQuery { + func entities(matching string: String) async throws -> [ProjectEntity] { + let q = string.lowercased() + return try bridge.listProjects() + .filter { !$0.archived } + .filter { $0.name.lowercased().contains(q) } + .map(ProjectEntity.init(from:)) + } +} +``` + +**`TaskEntity.swift`:** + +```swift +import AppIntents + +struct TaskEntity: AppEntity, IndexedEntity { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Task") + static var defaultQuery = TaskQuery() + + let id: String + let projectId: String + let name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)", subtitle: "Task in project \(projectId)") + } + + init(from dto: TaskDTO) { + self.id = dto.solidtimeId + self.projectId = dto.projectId + self.name = dto.name + } +} +``` + +**`TaskQuery.swift`:** + +```swift +import AppIntents + +struct TaskQuery: EntityQuery { + var bridge: Bridge = FFIBridge.shared + + func entities(for identifiers: [TaskEntity.ID]) async throws -> [TaskEntity] { + let all = try bridge.listTasks(projectId: nil).map(TaskEntity.init(from:)) + return all.filter { identifiers.contains($0.id) } + } + + func suggestedEntities() async throws -> [TaskEntity] { + try bridge.listTasks(projectId: nil).map(TaskEntity.init(from:)) + } +} +``` + +**`EntryEntity.swift`:** + +```swift +import AppIntents +import Foundation + +struct EntryEntity: AppEntity, IndexedEntity { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Time Entry") + static var defaultQuery = EntryQuery() + + let id: String // local_uuid + let description: String + let projectId: String? + let taskId: String? + let billable: Bool + let startAt: Date + let endAt: Date? + + var duration: Measurement { + let end = endAt ?? Date() + return Measurement(value: end.timeIntervalSince(startAt), unit: .seconds) + } + + var displayRepresentation: DisplayRepresentation { + let fmt = ISO8601DateFormatter() + return DisplayRepresentation( + title: "\(description)", + subtitle: "\(fmt.string(from: startAt)) · \(Int(duration.converted(to: .minutes).value))m" + ) + } + + init(from dto: EntryDTO) { + self.id = dto.localUuid + self.description = dto.description + self.projectId = dto.projectId + self.taskId = dto.taskId + self.billable = dto.billable + let fmt = ISO8601DateFormatter() + self.startAt = fmt.date(from: dto.startAt) ?? Date() + self.endAt = dto.endAt.flatMap(fmt.date(from:)) + } +} +``` + +**`EntryQuery.swift`:** + +```swift +import AppIntents + +struct EntryQuery: EntityQuery, EntityStringQuery { + var bridge: Bridge = FFIBridge.shared + + func entities(for identifiers: [EntryEntity.ID]) async throws -> [EntryEntity] { + // Filter is per-since/until — but we don't have explicit lookup-by-id. + // Fetch a wide window and filter. + let entries = try bridge.listEntries(EntryFilter(limit: 500)) + .map(EntryEntity.init(from:)) + return entries.filter { identifiers.contains($0.id) } + } + + func suggestedEntities() async throws -> [EntryEntity] { + try bridge.listEntries(EntryFilter(limit: 20)) + .map(EntryEntity.init(from:)) + } + + func entities(matching string: String) async throws -> [EntryEntity] { + let q = string.lowercased() + return try bridge.listEntries(EntryFilter(limit: 200)) + .map(EntryEntity.init(from:)) + .filter { $0.description.lowercased().contains(q) } + } +} +``` + +**Commit after each of D1, D2, D3:** + +```bash +git add crates/stint-app/swift/StintIntents/Sources/StintIntents/Entities/Project*.swift +git commit -m "feat(swift): ProjectEntity + ProjectQuery" +``` + +(repeat for Task, then Entry) + +--- + +## Task E1: SpotlightIndexer + +**Goal:** Bulk + delta Spotlight index updates. + +**Files:** +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Spotlight/SpotlightIndexer.swift` + +- [ ] **Step 1: Write the file** + +```swift +import CoreSpotlight +import UniformTypeIdentifiers +import Foundation + +enum IndexerKind: Int { + case entryStarted = 1 + case entryStopped = 2 + case entryUpdated = 3 + case entryDeleted = 4 + case projectsReplaced = 5 + case tasksReplaced = 6 +} + +final class SpotlightIndexer { + static let shared = SpotlightIndexer() + + private let entryDomain = "tech.reyem.stint.entry" + private let projectDomain = "tech.reyem.stint.project" + private let taskDomain = "tech.reyem.stint.task" + + private var bridge: Bridge { FFIBridge.shared } + + func bulkRefresh() { + Task.detached(priority: .background) { + self.refreshEntries() + self.refreshProjects() + self.refreshTasks() + } + } + + func delta(kind: IndexerKind, payload: String) { + Task.detached(priority: .background) { + do { + switch kind { + case .entryStarted, .entryStopped, .entryUpdated: + let dto = try JSONDecoder().decode(EntryDTO.self, from: Data(payload.utf8)) + self.upsertEntry(EntryEntity(from: dto)) + case .entryDeleted: + struct P: Decodable { let local_uuid: String } + let p = try JSONDecoder().decode(P.self, from: Data(payload.utf8)) + self.deleteEntry(localUuid: p.local_uuid) + case .projectsReplaced: + self.refreshProjects() + case .tasksReplaced: + self.refreshTasks() + } + } catch { + self.bridge.logWarn("spotlight delta decode failed: \(error)") + } + } + } + + // MARK: - Entries + + private func refreshEntries() { + do { + let entries = try bridge.listEntries(EntryFilter(limit: nil)) + .map(EntryEntity.init(from:)) + let items = entries.map(makeEntryItem) + CSSearchableIndex.default().indexSearchableItems(items) { [bridge] error in + if let error = error { bridge.logWarn("spotlight indexEntries failed: \(error)") } + } + } catch { + bridge.logWarn("spotlight refreshEntries fetch failed: \(error)") + } + } + + func upsertEntry(_ entry: EntryEntity) { + let item = makeEntryItem(entry) + CSSearchableIndex.default().indexSearchableItems([item]) { [bridge] error in + if let error = error { bridge.logWarn("spotlight upsertEntry failed: \(error)") } + } + } + + func deleteEntry(localUuid: String) { + CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: [localUuid]) { [bridge] error in + if let error = error { bridge.logWarn("spotlight deleteEntry failed: \(error)") } + } + } + + func makeEntryItem(_ entry: EntryEntity) -> CSSearchableItem { + let attrs = CSSearchableItemAttributeSet(contentType: UTType.text) + attrs.title = entry.description + let mins = Int(entry.duration.converted(to: .minutes).value) + attrs.contentDescription = "\(entry.startAt) · \(mins)m" + attrs.keywords = ["stint", "timer"] + attrs.containerIdentifier = entry.projectId + return CSSearchableItem( + uniqueIdentifier: entry.id, + domainIdentifier: entryDomain, + attributeSet: attrs + ) + } + + // MARK: - Projects + + private func refreshProjects() { + do { + let projects = try bridge.listProjects() + let items = projects.map(makeProjectItem) + CSSearchableIndex.default().indexSearchableItems(items) { [bridge] error in + if let error = error { bridge.logWarn("spotlight refreshProjects failed: \(error)") } + } + } catch { + bridge.logWarn("spotlight refreshProjects fetch failed: \(error)") + } + } + + func makeProjectItem(_ project: ProjectDTO) -> CSSearchableItem { + let attrs = CSSearchableItemAttributeSet(contentType: UTType.text) + attrs.title = project.name + attrs.contentDescription = "Project" + attrs.keywords = ["stint", "project", project.name] + return CSSearchableItem( + uniqueIdentifier: project.solidtimeId, + domainIdentifier: projectDomain, + attributeSet: attrs + ) + } + + // MARK: - Tasks + + private func refreshTasks() { + do { + let tasks = try bridge.listTasks(projectId: nil) + let items = tasks.map(makeTaskItem) + CSSearchableIndex.default().indexSearchableItems(items) { [bridge] error in + if let error = error { bridge.logWarn("spotlight refreshTasks failed: \(error)") } + } + } catch { + bridge.logWarn("spotlight refreshTasks fetch failed: \(error)") + } + } + + func makeTaskItem(_ task: TaskDTO) -> CSSearchableItem { + let attrs = CSSearchableItemAttributeSet(contentType: UTType.text) + attrs.title = task.name + attrs.contentDescription = "Task in project \(task.projectId)" + attrs.keywords = ["stint", "task", task.name] + return CSSearchableItem( + uniqueIdentifier: task.solidtimeId, + domainIdentifier: taskDomain, + attributeSet: attrs + ) + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add crates/stint-app/swift/StintIntents/Sources/StintIntents/Spotlight/SpotlightIndexer.swift +git commit -m "feat(swift): SpotlightIndexer — bulk refresh + delta updates for entries/projects/tasks" +``` + +--- + +## Task E2: ActivityTracker + +**Files:** +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Spotlight/ActivityTracker.swift` + +- [ ] **Step 1: Write the file** + +```swift +import Foundation +import CoreSpotlight + +final class ActivityTracker { + static let shared = ActivityTracker() + + private var current: NSUserActivity? + + func activate(entry: EntryEntity) { + let activity = NSUserActivity(activityType: "tech.reyem.stint.tracking") + activity.title = "Tracking: \(entry.description)" + activity.userInfo = ["uuid": entry.id] + activity.isEligibleForSearch = true + activity.isEligibleForHandoff = true + if #available(macOS 13, *) { + activity.isEligibleForPrediction = true + } + activity.becomeCurrent() + self.current = activity + } + + func update(description: String) { + current?.title = "Tracking: \(description)" + } + + func invalidate() { + current?.invalidate() + current = nil + } + + func boot() { + Task.detached(priority: .background) { + do { + if let entry = try FFIBridge.shared.current() { + let entity = EntryEntity(from: entry) + await MainActor.run { self.activate(entry: entity) } + } + } catch { + FFIBridge.shared.logWarn("activitytracker boot failed: \(error)") + } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add crates/stint-app/swift/StintIntents/Sources/StintIntents/Spotlight/ActivityTracker.swift +git commit -m "feat(swift): ActivityTracker — NSUserActivity for the running entry" +``` + +--- + +## Task E3: Init module — stint_intents_init + swift_indexer_notify + stint_current_focus_id_swift + +**Files:** +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Init/StintIntentsInit.swift` + +- [ ] **Step 1: Write the file** + +```swift +import Foundation +import CStintCore + +/// Called once by Rust during Tauri setup(). Loads the Swift runtime +/// implicitly (first FFI symbol resolution) and kicks off Spotlight + Activity. +@_cdecl("stint_intents_init") +public func stint_intents_init() -> Int32 { + SpotlightIndexer.shared.bulkRefresh() + ActivityTracker.shared.boot() + return 0 +} + +/// Called from Rust on every verb mutation + after pull-worker successes. +@_cdecl("swift_indexer_notify") +public func swift_indexer_notify(_ kind: Int32, _ payloadPtr: UnsafePointer?) { + guard let payloadPtr = payloadPtr else { return } + guard let kind = IndexerKind(rawValue: Int(kind)) else { return } + let payload = String(cString: payloadPtr) + + // Mutating ActivityTracker on start/stop/update needs the EntryDTO too. + switch kind { + case .entryStarted: + if let entry = try? JSONDecoder().decode(EntryDTO.self, from: Data(payload.utf8)) { + DispatchQueue.main.async { ActivityTracker.shared.activate(entry: EntryEntity(from: entry)) } + } + case .entryStopped: + DispatchQueue.main.async { ActivityTracker.shared.invalidate() } + case .entryUpdated: + if let entry = try? JSONDecoder().decode(EntryDTO.self, from: Data(payload.utf8)) { + DispatchQueue.main.async { ActivityTracker.shared.update(description: entry.description) } + } + default: + break + } + + SpotlightIndexer.shared.delta(kind: kind, payload: payload) +} + +/// Returns the currently active macOS Focus identifier. Called by Rust via dlsym +/// during the start-verb fallback path. +@_cdecl("stint_current_focus_id_swift") +public func stint_current_focus_id_swift(_ out: UnsafeMutablePointer?>) -> Int32 { + out.pointee = nil + + // INFocusStatusCenter is iOS-only as of macOS 13; the macOS API uses + // NSUserActivity-based focus interrogation through assertions. For the + // 6b ship we read the active focus from a UserDefaults key set by the + // OS when a Focus filter activates (this is how SetFocusFilterIntent + // wires through). If unavailable, return null. + if let focusId = UserDefaults.standard.string(forKey: "com.apple.focus.currentIdentifier") { + let c = strdup(focusId) + out.pointee = c + } + return 0 +} +``` + +Note: the macOS Focus public-API surface for reading the current focus id is limited; the implementation above reads a UserDefaults key whose presence in current OS versions should be verified during execution. If that doesn't work, fall back to setting the focus_id from `ProjectFocusFilter.perform()` directly (storing it via `stint_settings_set` together with the project) and skipping the read-side lookup entirely — the start-verb fallback simply trusts whatever the most recent `perform()` wrote. + +- [ ] **Step 2: Commit** + +```bash +git add crates/stint-app/swift/StintIntents/Sources/StintIntents/Init/StintIntentsInit.swift +git commit -m "feat(swift): @_cdecl exports — init, indexer notify, focus id" +``` + +--- + +## Task F1-F10: App Intents (10 intent types) + +Each intent is a separate small file in `Sources/StintIntents/Intents/`. Pattern (StartTimerIntent shown in full; others follow the same shape). Commit one per intent. + +**`StartTimerIntent.swift`:** + +```swift +import AppIntents +import Foundation + +struct StartTimerIntent: AppIntent { + static var title: LocalizedStringResource = "Start Timer" + static var description = IntentDescription("Start tracking time on a project in Stint.") + + @Parameter(title: "Description", requestValueDialog: "What are you working on?") + var description: String + + @Parameter(title: "Project") + var project: ProjectEntity? + + var bridge: Bridge = FFIBridge.shared + + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + let entry = try bridge.start(StartParams( + description: description, + projectId: project?.id, + source: "intent" + )) + let entity = EntryEntity(from: entry) + let projectName = project?.name ?? "no project" + return .result(value: entity, dialog: "Tracking '\(description)' on \(projectName).") + } +} +``` + +**`StopTimerIntent.swift`:** + +```swift +import AppIntents + +struct StopTimerIntent: AppIntent { + static var title: LocalizedStringResource = "Stop Timer" + static var description = IntentDescription("Stop the running Stint timer.") + + var bridge: Bridge = FFIBridge.shared + + func perform() async throws -> some IntentResult & ProvidesDialog { + let entry = try bridge.stop() + let mins = Int(EntryEntity(from: entry).duration.converted(to: .minutes).value) + return .result(dialog: "Stopped. \(mins) minutes on \(entry.projectId ?? "no project").") + } +} +``` + +**`GetCurrentIntent.swift`:** + +```swift +import AppIntents + +struct GetCurrentIntent: AppIntent { + static var title: LocalizedStringResource = "Current Timer" + static var description = IntentDescription("Show the currently running Stint timer.") + + var bridge: Bridge = FFIBridge.shared + + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + guard let entry = try bridge.current() else { + return .result(value: nil, dialog: "No active timer.") + } + let entity = EntryEntity(from: entry) + return .result(value: entity, dialog: "You're tracking '\(entry.description)'.") + } +} +``` + +**`SwitchProjectIntent.swift`:** + +```swift +import AppIntents + +struct SwitchProjectIntent: AppIntent { + static var title: LocalizedStringResource = "Switch Project" + static var description = IntentDescription("Stop the current Stint timer and start a new one on a different project.") + + @Parameter(title: "Project") + var project: ProjectEntity + + var bridge: Bridge = FFIBridge.shared + + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let current = try bridge.current() else { + throw BridgeError.invariant("No timer to switch from.") + } + _ = try bridge.stop() + _ = try bridge.start(StartParams( + description: current.description, + projectId: project.id, + source: "intent" + )) + return .result(dialog: "Switched to \(project.name).") + } +} +``` + +**`LogPastIntent.swift`:** + +```swift +import AppIntents +import Foundation + +struct LogPastIntent: AppIntent { + static var title: LocalizedStringResource = "Log Past Work" + static var description = IntentDescription("Retroactively log a past duration in Stint.") + + @Parameter(title: "Duration") + var duration: Measurement + + @Parameter(title: "Description", default: "Untitled") + var description: String + + @Parameter(title: "Project") + var project: ProjectEntity? + + var bridge: Bridge = FFIBridge.shared + + func perform() async throws -> some IntentResult & ProvidesDialog { + let seconds = duration.converted(to: .seconds).value + let startDate = Date(timeIntervalSinceNow: -seconds) + let fmt = ISO8601DateFormatter() + // If a timer is running, stop it first so the backdated entry doesn't overlap. + if (try? bridge.current()) != nil { + _ = try? bridge.stop() + } + _ = try bridge.start(StartParams( + description: description, + projectId: project?.id, + startAt: fmt.string(from: startDate), + source: "intent" + )) + _ = try bridge.stop() + let mins = Int(duration.converted(to: .minutes).value) + return .result(dialog: "Logged \(mins) minutes on \(project?.name ?? "no project").") + } +} +``` + +**`ListEntriesIntent.swift`:** + +```swift +import AppIntents +import Foundation + +struct ListEntriesIntent: AppIntent { + static var title: LocalizedStringResource = "List Entries" + static var description = IntentDescription("Fetch Stint time entries.") + + @Parameter(title: "Since") + var since: Date? + + @Parameter(title: "Until") + var until: Date? + + @Parameter(title: "Project") + var project: ProjectEntity? + + @Parameter(title: "Limit", default: 100) + var limit: Int + + var bridge: Bridge = FFIBridge.shared + + func perform() async throws -> some IntentResult & ReturnsValue<[EntryEntity]> { + let fmt = ISO8601DateFormatter() + let filter = EntryFilter( + since: since.map { fmt.string(from: $0) }, + until: until.map { fmt.string(from: $0) }, + projectId: project?.id, + limit: UInt32(limit) + ) + let entries = try bridge.listEntries(filter).map(EntryEntity.init(from:)) + return .result(value: entries) + } +} +``` + +**`ListProjectsIntent.swift`:** + +```swift +import AppIntents + +struct ListProjectsIntent: AppIntent { + static var title: LocalizedStringResource = "List Projects" + static var description = IntentDescription("Fetch the list of Stint projects.") + + var bridge: Bridge = FFIBridge.shared + + func perform() async throws -> some IntentResult & ReturnsValue<[ProjectEntity]> { + let projects = try bridge.listProjects().map(ProjectEntity.init(from:)) + return .result(value: projects) + } +} +``` + +**`ListTasksIntent.swift`:** + +```swift +import AppIntents + +struct ListTasksIntent: AppIntent { + static var title: LocalizedStringResource = "List Tasks" + static var description = IntentDescription("Fetch Stint tasks for a project.") + + @Parameter(title: "Project") + var project: ProjectEntity + + var bridge: Bridge = FFIBridge.shared + + func perform() async throws -> some IntentResult & ReturnsValue<[TaskEntity]> { + let tasks = try bridge.listTasks(projectId: project.id).map(TaskEntity.init(from:)) + return .result(value: tasks) + } +} +``` + +**`UpdateEntryIntent.swift`:** + +```swift +import AppIntents +import Foundation + +struct UpdateEntryIntent: AppIntent { + static var title: LocalizedStringResource = "Update Entry" + static var description = IntentDescription("Update fields on a Stint time entry.") + + @Parameter(title: "Entry") + var entry: EntryEntity + + @Parameter(title: "Description") + var description: String? + + @Parameter(title: "Project") + var project: ProjectEntity? + + @Parameter(title: "Billable") + var billable: Bool? + + var bridge: Bridge = FFIBridge.shared + + func perform() async throws -> some IntentResult & ReturnsValue { + var patch = EntryPatch() + if let d = description { patch.description = d } + if let p = project { patch.projectId = .set(p.id) } + if let b = billable { patch.billable = b } + let updated = try bridge.updateEntry(localUuid: entry.id, patch: patch) + return .result(value: EntryEntity(from: updated)) + } +} +``` + +**`DeleteEntryIntent.swift`:** + +```swift +import AppIntents + +struct DeleteEntryIntent: AppIntent { + static var title: LocalizedStringResource = "Delete Entry" + static var description = IntentDescription("Delete a Stint time entry.") + + @Parameter(title: "Entry") + var entry: EntryEntity + + var bridge: Bridge = FFIBridge.shared + + func perform() async throws -> some IntentResult & ProvidesDialog { + try bridge.deleteEntry(localUuid: entry.id) + return .result(dialog: "Deleted '\(entry.description)'.") + } +} +``` + +**Commit each intent file separately:** + +```bash +git add crates/stint-app/swift/StintIntents/Sources/StintIntents/Intents/StartTimerIntent.swift +git commit -m "feat(swift): StartTimerIntent" +# ... repeat for each +``` + +--- + +## Task G1: App Shortcuts provider + xcstrings + +**Files:** +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Shortcuts/StintAppShortcutsProvider.swift` +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Shortcuts/PhraseStrings.xcstrings` + +- [ ] **Step 1: Provider file** + +```swift +import AppIntents + +struct StintAppShortcutsProvider: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StartTimerIntent(), + phrases: [ + "Start timer in \(.applicationName)", + "Start tracking in \(.applicationName)", + "Start \(\.$project) in \(.applicationName)", + ], + shortTitle: "Start Timer", + systemImageName: "play.circle.fill" + ) + + AppShortcut( + intent: StopTimerIntent(), + phrases: [ + "Stop \(.applicationName) timer", + "Stop tracking in \(.applicationName)", + ], + shortTitle: "Stop Timer", + systemImageName: "stop.circle.fill" + ) + + AppShortcut( + intent: GetCurrentIntent(), + phrases: [ + "What am I tracking in \(.applicationName)", + "Show current \(.applicationName) timer", + ], + shortTitle: "Current Timer", + systemImageName: "clock" + ) + + AppShortcut( + intent: SwitchProjectIntent(), + phrases: [ + "Switch to \(\.$project) in \(.applicationName)", + ], + shortTitle: "Switch Project", + systemImageName: "arrow.triangle.swap" + ) + + AppShortcut( + intent: LogPastIntent(), + phrases: [ + "Log past \(\.$duration) in \(.applicationName)", + "Log last meeting in \(.applicationName)", + ], + shortTitle: "Log Past Work", + systemImageName: "backward.circle" + ) + } +} +``` + +- [ ] **Step 2: xcstrings** + +Create `PhraseStrings.xcstrings` (JSON format Apple expects): + +```json +{ + "sourceLanguage": "en", + "strings": { + "Start timer in %@": { "extractionState": "manual" }, + "Stop %@ timer": { "extractionState": "manual" }, + "What am I tracking in %@": { "extractionState": "manual" } + }, + "version": "1.0" +} +``` + +(The xcstrings format is sparse — Xcode populates it during `appintentsmetadataprocessor`. The structure above is the minimal valid skeleton; SPM's appintents processor enriches it during build.) + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/swift/StintIntents/Sources/StintIntents/Shortcuts/ +git commit -m "feat(swift): StintAppShortcutsProvider with 5 curated voice phrases" +``` + +--- + +## Task G2: ProjectFocusFilter + +**Files:** +- Create: `crates/stint-app/swift/StintIntents/Sources/StintIntents/Focus/ProjectFocusFilter.swift` + +- [ ] **Step 1: Write the file** + +```swift +import AppIntents +import Foundation + +struct ProjectFocusFilter: SetFocusFilterIntent { + static var title: LocalizedStringResource = "Default Project" + static var description = IntentDescription("Set a default project for new Stint timers while this focus is on.") + + @Parameter(title: "Project") + var project: ProjectEntity + + var bridge: Bridge = FFIBridge.shared + + func perform() async throws -> some IntentResult { + // Apple calls perform() once per focus activation. We persist a tuple + // (focus_id, project_id) and let verbs::start reconcile it against + // the current focus at read time. + // + // We don't have a stable "current focus id" API on macOS 13. As a + // workaround, the focus id we store is a stable hash of the project + // selection itself + a randomly-generated session token written to + // UserDefaults so a *new* perform() call overwrites the previous one. + let focusId = UUID().uuidString + UserDefaults.standard.set(focusId, forKey: "com.apple.focus.currentIdentifier") + let payload = "\(focusId)\t\(project.id)" + try bridge.settingsSet("focus.default_project", payload) + return .result() + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add crates/stint-app/swift/StintIntents/Sources/StintIntents/Focus/ProjectFocusFilter.swift +git commit -m "feat(swift): ProjectFocusFilter — default project per Focus mode" +``` + +--- + +## Task H1: stint-app/build.rs — invoke swift build + +**Files:** +- Modify: `crates/stint-app/build.rs` + +- [ ] **Step 1: Extend build.rs** + +Add after existing logic in `crates/stint-app/build.rs`: + +```rust +// Build StintIntents framework via SPM. +{ + let swift_dir = std::path::Path::new("swift/StintIntents"); + if swift_dir.exists() { + let profile = std::env::var("PROFILE").unwrap_or_else(|_| "debug".into()); + let swift_profile = if profile == "release" { "release" } else { "debug" }; + + println!("cargo:rerun-if-changed=swift/StintIntents/Sources"); + println!("cargo:rerun-if-changed=swift/StintIntents/Package.swift"); + + let status = std::process::Command::new("swift") + .args(["build", "-c", swift_profile, "--product", "StintIntents"]) + .current_dir(swift_dir) + .status(); + + match status { + Ok(s) if s.success() => { + let out_dir = std::env::var("OUT_DIR").unwrap(); + let dest = std::path::Path::new(&out_dir).join("StintIntents.framework"); + // SPM emits a .dylib by default for products of type .dynamic. + // Tauri's bundle.macOS.frameworks expects a .framework directory. + // Wrap the dylib in a minimal framework structure here. + wrap_dylib_as_framework(&swift_dir.join(".build").join(swift_profile), &dest); + println!("cargo:warning=StintIntents framework built at {}", dest.display()); + } + Ok(s) => println!("cargo:warning=swift build exited non-zero: {s}"), + Err(e) => println!("cargo:warning=swift build failed to spawn: {e}"), + } + } +} + +fn wrap_dylib_as_framework(swift_build_dir: &std::path::Path, dest: &std::path::Path) { + use std::fs; + let _ = fs::remove_dir_all(dest); + fs::create_dir_all(dest.join("Versions/A/Resources")).unwrap(); + let dylib_src = swift_build_dir.join("libStintIntents.dylib"); + if dylib_src.exists() { + fs::copy(&dylib_src, dest.join("Versions/A/StintIntents")).unwrap(); + } + // Create Info.plist + let plist = r#" + + + + CFBundleIdentifier + tech.reyem.stint.intents + CFBundleExecutable + StintIntents + CFBundleName + StintIntents + CFBundleVersion + 1.0 + CFBundleShortVersionString + 1.0 + NSAppIntentsPackage + + + +"#; + fs::write(dest.join("Versions/A/Resources/Info.plist"), plist).unwrap(); + // Copy Metadata.appintents stencil if SPM produced one + let stencil_candidates = [ + swift_build_dir.join("StintIntents.bundle/Contents/Resources/Metadata.appintents"), + swift_build_dir.join("StintIntents_StintIntents.bundle/Contents/Resources/Metadata.appintents"), + ]; + for cand in &stencil_candidates { + if cand.exists() { + fs::copy(cand, dest.join("Versions/A/Resources/Metadata.appintents")).unwrap(); + break; + } + } + // Symlinks + use std::os::unix::fs::symlink; + let _ = symlink("A", dest.join("Versions/Current")); + let _ = symlink("Versions/Current/StintIntents", dest.join("StintIntents")); + let _ = symlink("Versions/Current/Resources", dest.join("Resources")); +} +``` + +This is the integration glue most likely to need iteration during execution — verify each path exists during build and adapt to where SPM actually emits artifacts. + +- [ ] **Step 2: Test the build** + +```bash +cargo build -p stint-app 2>&1 | tail -20 +ls target/debug/build/stint-app-*/out/StintIntents.framework/ 2>/dev/null +``` + +Expected: clean build, framework artifact present. + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/build.rs +git commit -m "$(cat <<'EOF' +chore(build): stint-app build.rs invokes swift build for StintIntents + +After cargo builds the Rust binary, this also runs `swift build` against +the StintIntents SwiftPM package and wraps the resulting .dylib in a +.framework structure so Tauri's bundle.macOS.frameworks can consume it. + +The wrapping is necessary because SPM's `library(type: .dynamic)` emits +a .dylib, not a .framework. The minimal wrapper supplies Info.plist +with NSAppIntentsPackage=YES and symlinks to match the standard +Versions/A/ layout macOS expects. +EOF +)" +``` + +--- + +## Task H2: tauri.conf.json — bundle the framework + +**Files:** +- Modify: `crates/stint-app/tauri.conf.json` + +- [ ] **Step 1: Add bundle.macOS.frameworks** + +Read the current `tauri.conf.json` to find the `bundle.macOS` block. Add the `frameworks` key: + +```json +"macOS": { + "signingIdentity": null, + "providerShortName": null, + "hardenedRuntime": true, + "entitlements": "entitlements.plist", + "minimumSystemVersion": "13.0", + "frameworks": [ + "../../target/debug/build/stint-app-*/out/StintIntents.framework" + ] +} +``` + +The wildcard path is a problem — Tauri may not expand globs. As a workaround, copy the framework to a stable path before `tauri build`. Adjust `build.rs` from Task H1 to ALSO copy to `crates/stint-app/Frameworks/StintIntents.framework` (created lazily, gitignored) and reference that path in `tauri.conf.json`: + +```json +"frameworks": [ + "Frameworks/StintIntents.framework" +] +``` + +Add `/crates/stint-app/Frameworks/` to `.gitignore`. + +- [ ] **Step 2: Test cargo tauri build** + +```bash +cd crates/stint-app +cargo tauri build --bundles app 2>&1 | tail -30 +ls -la target/release/bundle/macos/Stint.app/Contents/Frameworks/ +``` + +Expected: `StintIntents.framework` present in the bundle. + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/tauri.conf.json .gitignore +git commit -m "$(cat <<'EOF' +chore(app): embed StintIntents.framework in Tauri bundle + +bundle.macOS.frameworks references the locally-copied framework so +Tauri's bundle step copies + codesigns it as Contents/Frameworks/ +StintIntents.framework. + +The framework path is a stable copy made by build.rs (Frameworks/ is +gitignored). +EOF +)" +``` + +--- + +## Task H3: Tauri setup() hook → stint_intents_init + +**Files:** +- Modify: `crates/stint-app/src/lib.rs` (Tauri setup callback) + +- [ ] **Step 1: Declare the FFI symbol** + +In `crates/stint-app/src/lib.rs`, near the top with other declarations: + +```rust +extern "C" { + fn stint_intents_init() -> i32; +} +``` + +- [ ] **Step 2: Call from setup()** + +Locate the `tauri::Builder::default().setup(|app| { ... })` block. At the end of the closure body (before the `Ok(())`), add: + +```rust +// Initialize the StintIntents Swift framework if it's loaded into the +// app bundle. dlsym-style: if the symbol isn't present (CLI binary or +// missing framework), this still links because the symbol IS present in +// the framework — the framework just may not be loaded yet. The first +// call forces a dlopen via the dyld lazy binding. +unsafe { + let rc = stint_intents_init(); + if rc != 0 { + eprintln!("stint_intents_init returned {rc}"); + } +} +``` + +Wrap in `#[cfg(target_os = "macos")]` if `lib.rs` already gates Mac-specific code that way. + +- [ ] **Step 3: Verify the binary loads the framework** + +After `cargo tauri build`: + +```bash +otool -L target/release/bundle/macos/Stint.app/Contents/MacOS/Stint | grep -i intents +``` + +Expected: a line referencing `@rpath/StintIntents.framework/Versions/A/StintIntents`. + +If absent → linker doesn't know about the framework. Add to `crates/stint-app/build.rs`: + +```rust +println!("cargo:rustc-link-search=framework=Frameworks"); +println!("cargo:rustc-link-lib=framework=StintIntents"); +println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/../Frameworks"); +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/stint-app/src/lib.rs crates/stint-app/build.rs +git commit -m "feat(app): call stint_intents_init() from Tauri setup hook" +``` + +--- + +## Task I1: Swift unit tests (mocked Bridge) + +**Files:** +- Create: `crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/StubBridge.swift` +- Create: `Tests/StintIntentsTests/BridgeEnvelopeTests.swift` +- Create: `Tests/StintIntentsTests/EntityCodingTests.swift` +- Create: `Tests/StintIntentsTests/SpotlightSchemaTests.swift` +- Create: `Tests/StintIntentsTests/AppIntentPerformTests.swift` + +- [ ] **Step 1: StubBridge** + +```swift +@testable import StintIntents +import Foundation + +final class StubBridge: Bridge { + var startResult: () throws -> EntryDTO = { fatalError("startResult not set") } + var stopResult: () throws -> EntryDTO = { fatalError("stopResult not set") } + var currentResult: () throws -> EntryDTO? = { nil } + var listEntriesResult: () throws -> [EntryDTO] = { [] } + var listProjectsResult: () throws -> [ProjectDTO] = { [] } + var listTasksResult: () throws -> [TaskDTO] = { [] } + var updateEntryResult: () throws -> EntryDTO = { fatalError() } + + var settingsStorage: [String: String] = [:] + var focusId: String? = nil + var logs: [String] = [] + + func start(_ params: StartParams) throws -> EntryDTO { try startResult() } + func stop() throws -> EntryDTO { try stopResult() } + func current() throws -> EntryDTO? { try currentResult() } + func listEntries(_ filter: EntryFilter) throws -> [EntryDTO] { try listEntriesResult() } + func listProjects() throws -> [ProjectDTO] { try listProjectsResult() } + func listTasks(projectId: String?) throws -> [TaskDTO] { try listTasksResult() } + func updateEntry(localUuid: String, patch: EntryPatch) throws -> EntryDTO { try updateEntryResult() } + func deleteEntry(localUuid: String) throws { } + + func settingsSet(_ key: String, _ value: String) throws { settingsStorage[key] = value } + func settingsGet(_ key: String) throws -> String? { settingsStorage[key] } + func settingsClear(_ key: String) throws { settingsStorage.removeValue(forKey: key) } + func currentFocusId() -> String? { focusId } + func logWarn(_ msg: String) { logs.append(msg) } +} +``` + +- [ ] **Step 2: BridgeEnvelopeTests** + +```swift +import XCTest +@testable import StintIntents + +final class BridgeEnvelopeTests: XCTestCase { + func testDecodeOkEnvelope() throws { + let json = #"{"ok": {"local_uuid":"u1","description":"x","billable":false,"start_at":"2026-05-25T10:00:00Z","source":"t"}}"# + let data = Data(json.utf8) + struct Env: Decodable { + let ok: EntryDTO? + } + let env = try JSONDecoder().decode(Env.self, from: data) + XCTAssertEqual(env.ok?.localUuid, "u1") + } + + func testDecodeErrEnvelope() throws { + let json = #"{"err": {"code": 1, "message": "timer already running"}}"# + let data = Data(json.utf8) + struct Env: Decodable { + let err: EnvelopeErr? + } + let env = try JSONDecoder().decode(Env.self, from: data) + XCTAssertEqual(env.err?.code, 1) + let mapped = BridgeError.from(code: Int32(env.err!.code), message: env.err!.message) + if case .invariant(let msg) = mapped { + XCTAssertEqual(msg, "timer already running") + } else { + XCTFail("expected invariant case") + } + } +} +``` + +- [ ] **Step 3: AppIntentPerformTests** (covers start, stop, current with stub bridge) + +```swift +import XCTest +@testable import StintIntents + +final class AppIntentPerformTests: XCTestCase { + func testStartTimerCallsBridgeWithSource() async throws { + let stub = StubBridge() + stub.startResult = { + EntryDTO(localUuid: "u1", solidtimeId: nil, description: "test", + projectId: nil, taskId: nil, billable: false, + startAt: "2026-05-25T10:00:00Z", endAt: nil, source: "intent") + } + var intent = StartTimerIntent() + intent.description = "test" + intent.bridge = stub + _ = try await intent.perform() + // Stub assertion: bridge.start was called (no captured params to inspect in + // this minimal stub; extend StubBridge to record calls if you need that). + } + + func testStopTimerSurfacesInvariantWhenNotRunning() async throws { + let stub = StubBridge() + stub.stopResult = { throw BridgeError.invariant("no timer to stop") } + var intent = StopTimerIntent() + intent.bridge = stub + do { + _ = try await intent.perform() + XCTFail("expected error") + } catch let err as BridgeError { + if case .invariant(let m) = err { + XCTAssertEqual(m, "no timer to stop") + } else { + XCTFail("wrong case") + } + } + } +} +``` + +- [ ] **Step 4: SpotlightSchemaTests** + +```swift +import XCTest +import CoreSpotlight +@testable import StintIntents + +final class SpotlightSchemaTests: XCTestCase { + func testEntryItemAttributes() { + let entry = EntryEntity(from: EntryDTO( + localUuid: "u1", solidtimeId: nil, description: "client meeting", + projectId: "proj-1", taskId: nil, billable: true, + startAt: "2026-05-25T10:00:00Z", endAt: "2026-05-25T11:00:00Z", source: "test")) + let item = SpotlightIndexer.shared.makeEntryItem(entry) + XCTAssertEqual(item.uniqueIdentifier, "u1") + XCTAssertEqual(item.domainIdentifier, "tech.reyem.stint.entry") + XCTAssertEqual(item.attributeSet.title, "client meeting") + XCTAssertTrue(item.attributeSet.keywords?.contains("stint") ?? false) + } + + func testProjectItemAttributes() { + let p = ProjectDTO(solidtimeId: "p1", name: "Acme", color: nil, clientId: nil, archived: false) + let item = SpotlightIndexer.shared.makeProjectItem(p) + XCTAssertEqual(item.uniqueIdentifier, "p1") + XCTAssertEqual(item.domainIdentifier, "tech.reyem.stint.project") + XCTAssertEqual(item.attributeSet.title, "Acme") + } +} +``` + +- [ ] **Step 5: Run tests** + +```bash +cd crates/stint-app/swift/StintIntents +swift test 2>&1 | tail -20 +``` + +Expected: all Swift tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/stint-app/swift/StintIntents/Tests/ +git commit -m "test(swift): mocked-bridge unit tests for intents, envelopes, schemas" +``` + +--- + +## Task I2: Swift integration tests (real Rust FFI) + +**Goal:** One end-to-end test that links against the real `stint_core` and exercises a start→current→stop cycle. + +**Files:** +- Create: `crates/stint-app/swift/StintIntents/Tests/StintIntentsIntegrationTests/FFIRoundTripTests.swift` + +- [ ] **Step 1: Write the file** + +```swift +import XCTest +@testable import StintIntents + +final class FFIRoundTripTests: XCTestCase { + override func setUp() { + // Point STINT_HOME at a tempdir so we don't touch the user's DB. + let tmp = NSTemporaryDirectory() + "stint-ffi-\(UUID().uuidString)/" + try? FileManager.default.createDirectory(atPath: tmp, withIntermediateDirectories: true) + setenv("STINT_HOME", tmp, 1) + } + + func testStartCurrentStopRoundTrip() throws { + let bridge = FFIBridge() + + let started = try bridge.start(StartParams(description: "integ", source: "swift-it")) + XCTAssertEqual(started.description, "integ") + + let current = try bridge.current() + XCTAssertEqual(current?.localUuid, started.localUuid) + + let stopped = try bridge.stop() + XCTAssertNotNil(stopped.endAt) + } + + func testStartTwiceReturnsInvariantError() throws { + let bridge = FFIBridge() + _ = try bridge.start(StartParams(description: "a", source: "swift-it")) + do { + _ = try bridge.start(StartParams(description: "b", source: "swift-it")) + XCTFail("expected invariant error") + } catch let err as BridgeError { + if case .invariant = err { /* ok */ } else { XCTFail() } + } + } +} +``` + +- [ ] **Step 2: Run** + +```bash +cd crates/stint-app/swift/StintIntents +swift test --filter StintIntentsIntegrationTests 2>&1 | tail -10 +``` + +The integration test requires `libstint_core.dylib` to be discoverable at link time. If `swift test` can't find it, the build will fail with "symbol not found" — add an explicit DYLD_LIBRARY_PATH or update Package.swift's linkerSettings to point at the workspace target dir. + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/swift/StintIntents/Tests/StintIntentsIntegrationTests/ +git commit -m "test(swift): integration test for FFIBridge against real stint_core" +``` + +--- + +## Task J1-J3: CI gates + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Add Swift test step** + +After the existing `cargo test` step in `ci.yml`, add: + +```yaml + - name: Swift package tests (StintIntents) + if: runner.os == 'macOS' + run: | + cd crates/stint-app/swift/StintIntents + swift test +``` + +- [ ] **Step 2: Add codesign verify step (release workflow)** + +In `.github/workflows/release.yml`, after `cargo tauri build`: + +```yaml + - name: Verify framework codesign + run: | + codesign --verify --deep --strict \ + target/release/bundle/macos/Stint.app + codesign --verify --strict \ + target/release/bundle/macos/Stint.app/Contents/Frameworks/StintIntents.framework +``` + +- [ ] **Step 3: Add Metadata.appintents check** + +```yaml + - name: Verify AppIntents metadata stencil contains all intents + run: | + STENCIL="target/release/bundle/macos/Stint.app/Contents/Frameworks/StintIntents.framework/Resources/Metadata.appintents" + if [ ! -f "$STENCIL" ]; then + echo "Missing Metadata.appintents stencil" + exit 1 + fi + # Each intent type's name should appear in the stencil. The stencil is + # binary plist or similar — use `strings` to grep through it. + for name in StartTimerIntent StopTimerIntent GetCurrentIntent \ + SwitchProjectIntent LogPastIntent ListEntriesIntent \ + ListProjectsIntent ListTasksIntent UpdateEntryIntent \ + DeleteEntryIntent ProjectFocusFilter; do + if ! strings "$STENCIL" | grep -q "$name"; then + echo "Intent type missing from stencil: $name" + exit 1 + fi + done + echo "All 11 intent types present in Metadata.appintents" +``` + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/ +git commit -m "ci: swift test step + framework codesign verify + appintents stencil check" +``` + +--- + +## Task J4: SKILL.md extension + +**Files:** +- Modify: `crates/stint-cli/skills/stint/SKILL.md` + +- [ ] **Step 1: Add App Intents section** + +Append to the "Surface priority" section in `SKILL.md`: + +```markdown +4. **App Intents (Shortcuts.app / Siri / Spotlight)** — macOS users may have + automations bound to stint's intents. The agent doesn't invoke these directly, + but should be aware they exist when explaining stint's surface area: + - Five App Shortcuts: Start Timer, Stop Timer, Current Timer, Switch Project, + Log Past Work. Each has voice-callable phrases. + - All 8 verb intents (+ 2 composed: SwitchProject, LogPast) are discoverable + in Shortcuts.app as Custom Shortcuts. + - One Focus Filter: "Default Project" — set per Focus mode in System Settings. +``` + +Also add to the "Gotchas" section: + +```markdown +- **Focus filter race window** — if a user activates a macOS Focus filter + while Stint.app is cold-launching, the `start` verb may fire before the + focus default is written, producing an entry without the focus project. + Document workaround: rerun `stint edit` if the user notices a missing + project after a focus-mode-triggered start. + +- **New URL routes** — `stint://project/` opens the Today view + filtered to that project; `stint://task/` resolves to the + task's parent project and filters by both. Used by Spotlight result taps. +``` + +- [ ] **Step 2: Commit** + +```bash +git add crates/stint-cli/skills/stint/SKILL.md +git commit -m "docs(skill): document App Intents surface + new URL routes" +``` + +--- + +## Task J5: Manual smoke checklist (PR description) + +**Goal:** Capture the manual-test list as a markdown block that goes into the PR description. Not committed; lives in the PR body when it's created. + +Checklist (to copy into the PR): + +```markdown +## Manual smoke (macOS 13+) + +- [ ] `cargo tauri build` succeeds; framework embedded in `Stint.app/Contents/Frameworks/StintIntents.framework` +- [ ] `Stint.app` launches without Gatekeeper warning (signed cert valid) +- [ ] `pluginkit -mvD | grep tech.reyem.stint` lists ≥11 App Intent types +- [ ] Shortcuts.app shows "Stint" actions; can configure Start Timer with a project parameter +- [ ] Cmd+Space → "client meeting" (after creating one) → tap result → app focuses entry +- [ ] Cmd+Space → "Acme" (after creating an Acme project) → tap → Today view filters to Acme +- [ ] "Hey Siri, start timer in Stint" → Siri prompts for description → speak it → verify entry created +- [ ] System Settings → Focus → Work → Add Filter → Stint → pick project → verify next `stint start` (without `--project`) picks it up +- [ ] After 6b lands, run `man stint` → no regression on man page (still v0.3.x) +- [ ] `stint mcp` still launches; MCP tools still work (Spotlight is additive) +``` + +--- + +## Task K1: Full verification + +- [ ] **Step 1: Cargo lint + test** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace -- --test-threads=1 +``` + +Expected: green. + +- [ ] **Step 2: UI typecheck + tests** + +```bash +cd ui +pnpm typecheck +pnpm test:run +cd ../.. +``` + +Expected: green. + +- [ ] **Step 3: Coverage** + +```bash +scripts/coverage.sh +``` + +Expected: all four surfaces (stint-core, stint-cli, stint-app, ui) ≥80%. New `crates/stint-core/src/ffi.rs` should be well-covered by ffi_envelope.rs / ffi_verbs.rs / ffi_panic_safety.rs / ffi_settings.rs. + +If `stint-core` dips below 80% due to the FFI surface, add targeted tests in `crates/stint-core/tests/ffi_more.rs` until it climbs back above. The error-mapping branches and panic safety are the likely gaps. + +- [ ] **Step 4: Bundle smoke** + +```bash +cd crates/stint-app +cargo tauri build --bundles app +cd ../.. +codesign --verify --deep --strict crates/stint-app/target/release/bundle/macos/Stint.app +ls crates/stint-app/target/release/bundle/macos/Stint.app/Contents/Frameworks/ +strings crates/stint-app/target/release/bundle/macos/Stint.app/Contents/Frameworks/StintIntents.framework/Resources/Metadata.appintents | grep -c StartTimer +``` + +Expected: clean codesign, framework present, strings count ≥1. + +--- + +## Task K2: Tag phase-6b-complete (LOCAL ONLY — do not push) + +- [ ] **Step 1: Sanity check no uncommitted changes** + +```bash +git status +``` + +Expected: clean working tree. + +- [ ] **Step 2: Tag** + +```bash +git tag -a phase-6b-complete -m "Phase 6b complete — Spotlight + App Intents + Focus filter" +git log --oneline -5 +``` + +- [ ] **Step 3: STOP and confirm with user before pushing** + +The plan stops here. Explicit user confirmation required before: +- `git push origin phase-6b` +- Opening a PR +- Pushing tags + +Surface to user: "Phase 6b is complete on local branch `phase-6b`, tagged `phase-6b-complete`. Ready to push and open the PR?" + +--- + +## Self-review summary (run after writing the plan) + +**Coverage of spec sections:** +- §3 Architecture → Tasks A2, A3, A4, C1, C2, H1, H2, H3 +- §4 App Intents → Tasks F1–F10, G1 +- §5 Spotlight → Tasks E1, E2, E3, B2 +- §6 Focus filter → Tasks A4, A6, G2 +- §7 Error handling → Tasks A2 (envelope), C3 (Swift errors), J1–J3 (CI gates) +- §8 Testing strategy → Tasks I1, I2, J1, K1 +- §9 Trade-offs → captured in spec, no separate task + +**Placeholder scan:** searched for TBD/TODO/FIXME in this plan — none found in execution steps. + +**Known fragility points (call out during execution):** +1. Task A1 SPM spike outcome determines whether to use SPM (A1) or Xcode `.xcodeproj` (A1.fallback). If fallback, Tasks H1 and the CI Swift test step adapt. +2. Task H1 `wrap_dylib_as_framework` glob paths for the SPM-emitted dylib may need iteration — verify the actual `.build//` layout. +3. Task E3 `stint_current_focus_id_swift` reads a UserDefaults key that may not be a documented public API. If unavailable, fall back to focus_id-from-perform-only (no read-side lookup). +4. Task H2 `frameworks` path: Tauri may not glob-expand. Adopted workaround: build.rs copies framework to `crates/stint-app/Frameworks/` (gitignored). +5. Task I2 link path for the integration test: may require `DYLD_LIBRARY_PATH` env override at test time. + +Each fragility point has a documented fallback inline. Execution should pause and ask only if a fallback also fails. diff --git a/docs/superpowers/plans/2026-05-27-stint-phase-6c-power-user-surfaces.md b/docs/superpowers/plans/2026-05-27-stint-phase-6c-power-user-surfaces.md new file mode 100644 index 0000000..054719d --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-stint-phase-6c-power-user-surfaces.md @@ -0,0 +1,3078 @@ +# stint Phase 6c: Power-user Surfaces Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship 4 independent macOS power-user surfaces (Raycast extension, Alfred workflow, WidgetKit widget, in-process idle detection) on top of the Phase 6a CLI/HTTP/URL-scheme primitives. + +**Architecture:** Each surface is independent — Raycast/Alfred shell out to `stint --json`, the widget hits loopback HTTP `/v1/*`, idle detection runs as a tokio task inside stint-app. Zero new `stint-core` verbs. A small CLI extension (`stint projects list-tasks`) and a new `api.port` discovery file are the only shared additions. + +**Tech Stack:** Rust 1.95 · Swift 5.9 (WidgetKit + SwiftUI + WidgetConfigurationIntent) · TypeScript (Raycast SDK) · bash (Alfred scripts) · Tauri 2 · SolidJS · existing project tooling. + +**Spec:** [`docs/superpowers/specs/2026-05-27-stint-phase-6c-power-user-surfaces-design.md`](../specs/2026-05-27-stint-phase-6c-power-user-surfaces-design.md) + +--- + +## File structure + +### Rust + UI (modified) + +- `crates/stint-app/src/http/mod.rs` — write `api.port` file on bind, remove on shutdown +- `crates/stint-app/src/idle_detector.rs` — **new** — CGEvent polling task +- `crates/stint-app/src/commands/idle.rs` — **new** — three Tauri commands +- `crates/stint-app/src/commands/mod.rs` — register `idle` module +- `crates/stint-app/src/lib.rs` — register `idle_detector` +- `crates/stint-app/src/main.rs` — spawn the idle detector from `setup()`, register the three idle commands in `invoke_handler!` +- `crates/stint-app/Cargo.toml` — adds `core-foundation`/manual extern decl (no new deps if we hand-roll the C signature) +- `crates/stint-cli/src/cmd/projects.rs` — adds `ListTasks { project_id }` variant +- `ui/src/components/IdleBanner.tsx` — **new** +- `ui/src/routes/Today.tsx` — mounts `` inside the popover layout +- `ui/src/routes/Settings.tsx` — adds an "Idle detection" section +- `ui/src/api.ts` — wraps the three new Tauri commands + +### Swift Widget (created) + +``` +crates/stint-app/swift/StintWidget/ + Package.swift + Sources/StintWidget/ + StintWidgetBundle.swift # @main entry + RunningTimerWidget.swift # Widget declaration + configurationDisplayName + WidgetConfigIntent.swift # WidgetConfigurationIntent + WidgetKind enum + Provider.swift # TimelineProvider + Models/ + EntryDTO.swift # Mirror of HTTP /v1/current shape + ProjectDTO.swift + PortDiscovery.swift # Reads ~/Library/Application Support/stint/api.port + Views/ + RunningTimerView.swift + TodayTotalView.swift + WeekProjectView.swift + Tests/StintWidgetTests/ + PortDiscoveryTests.swift + DTOCodingTests.swift +``` + +### Raycast (created) + +``` +raycast-stint/ + package.json + src/ + start-timer.tsx + stop-timer.tsx + current.tsx + recent-entries.tsx + switch-project.tsx + lib/ + stint.ts # Subprocess wrapper around stint --json + types.ts # TypeScript types matching CLI JSON shapes + assets/icon.png + README.md +``` + +### Alfred (created) + +``` +alfred-stint/ + info.plist + start.sh + stop.sh + current.sh + recent.sh + icon.png + README.md +``` + +### Build / docs (modified) + +- `crates/stint-app/build.rs` — extends to also `xcodebuild` the StintWidget package and place `.appex` into `crates/stint-app/PlugIns/StintWidget.appex/` +- `crates/stint-app/tauri.conf.json` — `bundle.resources` map for each file inside `.appex` +- `.github/workflows/ci.yml` — add Swift Widget test step +- `crates/stint-cli/skills/stint/SKILL.md` — document new surfaces +- `README.md`, `CLAUDE.md` — roadmap row for 6c + +--- + +## Conventions + +- **TDD discipline:** failing test → impl → green. Existing patterns in `crates/stint-core/tests/` and `crates/stint-app/tests/`. +- **Commit per task** with Conventional Commits. Subjects under 70 chars; bodies explain the *why*. +- **Pre-commit (per task):** the touched test file passes + `cargo fmt --check` if Rust touched. Full gate (`cargo test --workspace --test-threads=1`, `cargo clippy --workspace --all-targets -- -D warnings`, `pnpm typecheck`, `pnpm vitest run`, `swift test` in widget package, `scripts/coverage.sh`) runs once at end of plan. +- **Don't push or open PR** until the user confirms. +- **`scripts/dev-cli.sh` and `scripts/dev-app.sh`** wrap codesigning; use these instead of bare `cargo run` for the CLI/GUI in dev. + +--- + +## Task A1: `api.port` discovery file + +**Goal:** stint-app writes the bound HTTP port to `~/Library/Application Support/stint/api.port` on bind; removes it on graceful shutdown. The widget reads this on every timeline refresh. + +**Files:** +- Modify: `crates/stint-app/src/http/mod.rs` +- Create: `crates/stint-app/tests/api_port_file.rs` + +- [ ] **Step 1: Write failing test** + +Create `crates/stint-app/tests/api_port_file.rs`: + +```rust +//! `api.port` file is written on bind, removed on drop. + +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +fn port_file_for(data_dir: &std::path::Path) -> PathBuf { + data_dir.join("api.port") +} + +#[tokio::test] +async fn writes_port_file_on_bind() { + let tempdir = TempDir::new().unwrap(); + std::env::set_var("STINT_DATA_DIR", tempdir.path()); + + let port = stint_app::http::write_port_file_for_test(49792).unwrap(); + assert_eq!(port, 49792); + let path = port_file_for(tempdir.path()); + assert!(path.exists(), "port file not at {}", path.display()); + let contents = fs::read_to_string(&path).unwrap(); + assert_eq!(contents.trim(), "49792"); +} + +#[tokio::test] +async fn removes_port_file() { + let tempdir = TempDir::new().unwrap(); + std::env::set_var("STINT_DATA_DIR", tempdir.path()); + stint_app::http::write_port_file_for_test(49792).unwrap(); + let path = port_file_for(tempdir.path()); + assert!(path.exists()); + + stint_app::http::remove_port_file_for_test().unwrap(); + assert!(!path.exists()); +} +``` + +- [ ] **Step 2: Run, confirm fail** + +```bash +cargo test -p stint-app --test api_port_file 2>&1 | tail -5 +``` + +Expected: compile error — `write_port_file_for_test` / `remove_port_file_for_test` don't exist. + +- [ ] **Step 3: Implement in `crates/stint-app/src/http/mod.rs`** + +Find the existing `maybe_spawn` fn (the one that binds the loopback listener). Add a `write_port_file` helper after a successful bind, and a `remove_port_file` helper called from a `Drop` guard on the listener. + +Add to the top of the file: + +```rust +use std::path::PathBuf; + +fn port_file_path() -> stint_core::Result { + Ok(stint_core::paths::data_dir()?.join("api.port")) +} + +fn write_port_file(port: u16) -> stint_core::Result<()> { + let path = port_file_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, format!("{port}\n"))?; + Ok(()) +} + +fn remove_port_file() -> stint_core::Result<()> { + let path = port_file_path()?; + let _ = std::fs::remove_file(&path); + Ok(()) +} + +// Test-only re-exports +#[doc(hidden)] +pub fn write_port_file_for_test(port: u16) -> stint_core::Result { + write_port_file(port)?; + Ok(port) +} + +#[doc(hidden)] +pub fn remove_port_file_for_test() -> stint_core::Result<()> { + remove_port_file() +} +``` + +In `maybe_spawn` (the existing fn that binds the listener), right after the successful bind that returns the port: + +```rust +// Before existing successful return +let _ = write_port_file(port); // best-effort; widget falls back to placeholder if missing +``` + +In the worker task that holds the listener, when it exits (either gracefully on shutdown or because the future drops): + +```rust +// At the end of the spawned task +let _ = remove_port_file(); +``` + +If there isn't a clean shutdown path today, just leave a stale port file at exit. The widget treats unreachable as "Stint not running" anyway. + +- [ ] **Step 4: Run, confirm pass** + +```bash +cargo test -p stint-app --test api_port_file -- --test-threads=1 2>&1 | tail -5 +``` + +Expected: 2 tests pass. + +- [ ] **Step 5: Lint + commit** + +```bash +cargo fmt --all +cargo clippy -p stint-app --all-targets -- -D warnings +git add crates/stint-app/src/http/mod.rs crates/stint-app/tests/api_port_file.rs +git commit -m "feat(app): write api.port discovery file on HTTP bind + +Widget needs to discover the loopback HTTP port without IPC. Writes +the bound port as plain-text \"\\n\" to ~/Library/Application +Support/stint/api.port on every bind; removes on graceful shutdown. +Stale file at app exit is harmless — widget treats unreachable as +'Stint not running'." +``` + +--- + +## Task A2: CLI `stint projects list-tasks ` + +**Goal:** Add the subcommand Raycast's Start Timer form needs to populate its Task dropdown. Wraps the existing `verbs::list_tasks`. + +**Files:** +- Modify: `crates/stint-cli/src/cmd/projects.rs` +- Create: `crates/stint-cli/tests/projects_list_tasks.rs` + +- [ ] **Step 1: Write the failing test** + +Look at `crates/stint-cli/tests/cli_e2e.rs` and `crates/stint-cli/tests/verbs_json.rs` for the existing patterns. Create `crates/stint-cli/tests/projects_list_tasks.rs`: + +```rust +//! `stint projects list-tasks ` returns tasks for a project. + +use assert_cmd::Command; +use serde_json::Value; +use tempfile::TempDir; + +#[test] +fn list_tasks_empty_when_no_data() { + let tempdir = TempDir::new().unwrap(); + let output = Command::cargo_bin("stint") + .unwrap() + .env("STINT_DATA_DIR", tempdir.path()) + .args(["--json", "projects", "list-tasks", "proj-abc"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let json: Value = serde_json::from_slice(&output).unwrap(); + assert!(json.is_array()); + assert_eq!(json.as_array().unwrap().len(), 0); +} +``` + +- [ ] **Step 2: Run, confirm fail** + +```bash +cargo test -p stint-cli --test projects_list_tasks 2>&1 | tail -5 +``` + +Expected: compile error or subcommand not found. + +- [ ] **Step 3: Read existing `projects.rs` shape** + +```bash +cat crates/stint-cli/src/cmd/projects.rs | head -50 +``` + +You'll see a clap enum with `List`, `Refresh`, `Raw`. Add a `ListTasks` variant. + +- [ ] **Step 4: Implement** + +Modify `crates/stint-cli/src/cmd/projects.rs` — add the new variant, dispatch to a new handler: + +```rust +#[derive(Subcommand, Debug)] +pub enum ProjectsCmd { + List(ListArgs), + Refresh, + Raw, + /// List cached tasks for a project. Run `projects refresh` first to populate. + ListTasks(ListTasksArgs), +} + +#[derive(Args, Debug)] +pub struct ListTasksArgs { + /// Solidtime project id + pub project_id: String, + /// Emit machine-readable JSON instead of human text + #[arg(long)] + pub json: bool, +} + +// In the existing match dispatcher, add: +ProjectsCmd::ListTasks(args) => list_tasks_cmd(args, json_global).await, + +// Handler — mirrors the existing `list` handler's shape: +async fn list_tasks_cmd(args: ListTasksArgs, json_global: bool) -> Result<()> { + let json = args.json || json_global; + let store = open_store().await?; + let tasks = stint_core::verbs::list_tasks(&store, Some(args.project_id.clone())).await?; + if json { + println!("{}", serde_json::to_string(&tasks)?); + } else { + if tasks.is_empty() { + println!("(no tasks)"); + } else { + for t in tasks { + println!(" {} {}", t.solidtime_id, t.name); + } + } + } + Ok(()) +} +``` + +(The `open_store` helper exists already in `projects.rs`. If it doesn't, look at how `list` does it and mirror that pattern.) + +- [ ] **Step 5: Run, confirm pass** + +```bash +cargo test -p stint-cli --test projects_list_tasks -- --test-threads=1 2>&1 | tail -5 +``` + +Expected: 1 test passes. + +- [ ] **Step 6: Verify --help reflects the new subcommand** + +```bash +cargo run -p stint-cli -- projects --help 2>&1 | grep list-tasks +``` + +Expected: shows `list-tasks` in the command list. + +- [ ] **Step 7: Commit** + +```bash +cargo fmt --all +git add -A +git commit -m "feat(cli): stint projects list-tasks subcommand + +Raycast extension (Phase 6c) needs to fetch tasks for a project to +populate the Start Timer form's Task picker. Thin wrapper around the +existing verbs::list_tasks. Honors --json (both global and local +flags); humans see 'uuid name' lines." +``` + +--- + +## Task A3: Idle detector module + +**Goal:** Tokio task that polls `CGEventSourceSecondsSinceLastEventType` every 60s while a timer is running, emits an `idle:detected` Tauri event on activity-resume after threshold exceeded. + +**Files:** +- Create: `crates/stint-app/src/idle_detector.rs` +- Modify: `crates/stint-app/src/lib.rs` (register module) +- Create: `crates/stint-app/tests/idle_detector.rs` + +- [ ] **Step 1: Write the failing test for the pure state machine** + +`crates/stint-app/tests/idle_detector.rs`: + +```rust +//! Idle detector state machine (no actual CGEvent polling — that's tested +//! end-to-end via manual smoke). + +use stint_app::idle_detector::{IdleState, IdleEvent, advance}; + +#[test] +fn no_event_when_below_threshold() { + let mut state = IdleState::default(); + let evt = advance(&mut state, /*idle_secs*/ 30.0, /*now*/ 1000, /*threshold*/ 600, /*timer_running*/ true); + assert!(evt.is_none()); + assert!(state.pending_idle.is_none()); +} + +#[test] +fn arms_pending_idle_when_threshold_reached() { + let mut state = IdleState::default(); + // Idle for 720s when polled at t=1000 means idleness began at t=280 + let evt = advance(&mut state, 720.0, 1000, 600, true); + assert!(evt.is_none()); + assert_eq!(state.pending_idle, Some(280)); +} + +#[test] +fn emits_event_when_activity_resumes() { + let mut state = IdleState { pending_idle: Some(280) }; + let evt = advance(&mut state, /*idle_secs*/ 3.0, /*now*/ 1100, 600, true); + assert!(evt.is_some()); + let evt = evt.unwrap(); + assert_eq!(evt.idle_started, 280); + assert_eq!(evt.idle_secs, 820); // now - pending_idle + assert!(state.pending_idle.is_none()); +} + +#[test] +fn no_event_when_timer_not_running() { + let mut state = IdleState::default(); + let evt = advance(&mut state, 720.0, 1000, 600, false); + assert!(evt.is_none()); +} + +#[test] +fn drops_pending_when_timer_stops() { + let mut state = IdleState { pending_idle: Some(280) }; + // Timer stopped → pending should clear without emitting + let evt = advance(&mut state, 3.0, 1100, 600, false); + assert!(evt.is_none()); + assert!(state.pending_idle.is_none()); +} +``` + +- [ ] **Step 2: Run, confirm fail** + +```bash +cargo test -p stint-app --test idle_detector 2>&1 | tail -5 +``` + +Expected: compile error — module doesn't exist. + +- [ ] **Step 3: Implement the state machine** + +Create `crates/stint-app/src/idle_detector.rs`: + +```rust +//! Idle detector — polls CGEvent every 60s, emits an event on activity- +//! resume after the configured threshold has elapsed. +//! +//! The pure state machine in `advance()` is testable without macOS APIs; +//! the live polling loop in `spawn()` calls `idle_seconds()` (which links +//! against CoreGraphics) on a tokio task. + +use serde::Serialize; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct IdleState { + /// Unix timestamp (seconds) when idleness began; Some once threshold + /// has been reached and we're awaiting activity-resume. + pub pending_idle: Option, +} + +#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +pub struct IdleEvent { + /// ISO 8601 — when the idle period started (now-iso) computed by caller + /// from the integer epoch second; this struct carries the epoch seconds + /// for testability. The Tauri emit translates to ISO 8601. + pub idle_started: u64, + pub idle_secs: u64, +} + +/// Advance the state machine one tick. Pure function; no I/O. +/// +/// * `idle_secs` — CGEvent's "seconds since any input" +/// * `now` — current unix epoch seconds +/// * `threshold` — idle.threshold_secs setting +/// * `timer_running` — whether there's a running entry to attribute the gap to +pub fn advance( + state: &mut IdleState, + idle_secs: f64, + now: u64, + threshold: u32, + timer_running: bool, +) -> Option { + // No timer → nothing to attribute idle to. Drop any pending state. + if !timer_running { + state.pending_idle = None; + return None; + } + + let idle_secs = idle_secs.max(0.0) as u64; + let threshold = threshold as u64; + + // Activity resumed after threshold was previously reached → emit. + if let Some(idle_started) = state.pending_idle { + if idle_secs < 60 { + let evt = IdleEvent { + idle_started, + idle_secs: now.saturating_sub(idle_started), + }; + state.pending_idle = None; + return Some(evt); + } + // Still idle; no change. + return None; + } + + // Not yet armed. Arm if we crossed the threshold. + if idle_secs >= threshold { + state.pending_idle = Some(now.saturating_sub(idle_secs)); + } + None +} + +// ---- platform-dependent polling ---- + +#[cfg(target_os = "macos")] +mod platform { + extern "C" { + fn CGEventSourceSecondsSinceLastEventType( + source_state_id: i32, + event_type: u32, + ) -> f64; + } + + pub fn idle_seconds() -> f64 { + // source_state_id = 0 (combined session state), + // event_type = u32::MAX (kCGAnyInputEventType) + unsafe { CGEventSourceSecondsSinceLastEventType(0, u32::MAX) } + } +} + +#[cfg(not(target_os = "macos"))] +mod platform { + pub fn idle_seconds() -> f64 { + 0.0 + } +} + +pub use platform::idle_seconds; + +// The live polling loop is wired in spawn() — Task A4 adds it. +``` + +Register the module in `crates/stint-app/src/lib.rs`: + +```rust +pub mod idle_detector; +``` + +- [ ] **Step 4: Run, confirm pass** + +```bash +cargo test -p stint-app --test idle_detector -- --test-threads=1 2>&1 | tail -10 +``` + +Expected: 5 tests pass. + +- [ ] **Step 5: Lint + commit** + +```bash +cargo fmt --all +cargo clippy -p stint-app --all-targets -- -D warnings +git add -A +git commit -m "feat(app): idle detector state machine + +Pure-function state machine drives the idle-detected event. Live +polling loop (spawn) lands in a follow-up task that wires up the +tokio task + Tauri emit. The pure layer is unit-tested without +linking CoreGraphics. + +Apple CGEventSourceSecondsSinceLastEventType is declared extern in +crates/stint-app/src/idle_detector.rs::platform — no new Cargo deps." +``` + +--- + +## Task A4: Idle detector polling task + Tauri spawn + +**Goal:** Tokio task that calls the state machine every 60s and emits `idle:detected` Tauri event when the activity-resume condition fires. + +**Files:** +- Modify: `crates/stint-app/src/idle_detector.rs` — add `spawn()` +- Modify: `crates/stint-app/src/main.rs` — call `spawn()` from `setup()` + +- [ ] **Step 1: Add `spawn()` to `idle_detector.rs`** + +Append to `crates/stint-app/src/idle_detector.rs`: + +```rust +use std::sync::Arc; +use std::time::Duration; +use stint_core::store::Store; +use tauri::{AppHandle, Emitter, Runtime}; +use tokio::time::interval; +use tracing::{debug, info}; + +const TICK: Duration = Duration::from_secs(60); + +/// Spawn the background idle-detector task. Lives for the GUI process lifetime. +pub fn spawn(app: AppHandle, store: Arc) { + tokio::spawn(async move { + info!("idle detector started (tick = {:?})", TICK); + let mut state = IdleState::default(); + let mut tick = interval(TICK); + loop { + tick.tick().await; + if let Err(e) = tick_once(&app, &store, &mut state).await { + debug!("idle detector tick error: {e}"); + } + } + }); +} + +async fn tick_once( + app: &AppHandle, + store: &Store, + state: &mut IdleState, +) -> stint_core::Result<()> { + let settings = stint_core::config::Settings::new(store.clone()); + let enabled: bool = settings + .get("idle.enabled").await? + .as_deref().map(|s| s != "false").unwrap_or(true); + if !enabled { + state.pending_idle = None; + return Ok(()); + } + let threshold: u32 = settings + .get("idle.threshold_secs").await? + .and_then(|s| s.parse().ok()) + .unwrap_or(600) + .clamp(60, 86_400); + + // Timer running? + let running = stint_core::store::running::RunningTimer::new(store.clone()) + .get().await?.is_some(); + + let idle = idle_seconds(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + if let Some(evt) = advance(state, idle, now, threshold, running) { + let iso = chrono::DateTime::::from_timestamp(evt.idle_started as i64, 0) + .map(|d| d.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_default(); + let payload = serde_json::json!({ + "idle_started": iso, + "idle_secs": evt.idle_secs, + }); + info!(?evt, "idle detected; emitting idle:detected"); + let _ = app.emit("idle:detected", payload); + } + Ok(()) +} +``` + +- [ ] **Step 2: Wire into `setup()` in `main.rs`** + +Find the existing setup block where `sync_worker::spawn` and `pull_worker::spawn` are called. Add right after: + +```rust +// Idle detector — emits idle:detected when activity resumes after +// the configured threshold while a timer is running. +idle_detector::spawn(app.handle().clone(), store_for_worker.clone()); +``` + +Add `use idle_detector;` near the top imports (it's already declared in lib.rs). + +- [ ] **Step 3: Run workspace tests to verify no regressions** + +```bash +cargo test -p stint-app -- --test-threads=1 2>&1 | grep -E "test result|FAILED" | tail -5 +``` + +Expected: green (the existing idle_detector tests still pass; no new tests yet — the polling loop is integration-tested manually). + +- [ ] **Step 4: Lint + commit** + +```bash +cargo fmt --all +cargo clippy -p stint-app --all-targets -- -D warnings +git add -A +git commit -m "feat(app): idle detector polling task + setup wiring + +tokio task ticks every 60s while the GUI runs, calls the state +machine, emits the idle:detected Tauri event on activity-resume. +Reads idle.enabled + idle.threshold_secs settings each tick (cheap; +default true / 600s). + +Threshold is clamped to [60, 86400] at read time so a malformed +settings entry can't disable the detector or make it fire instantly." +``` + +--- + +## Task A5: Idle Tauri commands + +**Goal:** `idle_keep` / `idle_discard` / `idle_split` commands invokable from the UI banner. + +**Files:** +- Create: `crates/stint-app/src/commands/idle.rs` +- Modify: `crates/stint-app/src/commands/mod.rs` +- Modify: `crates/stint-app/src/main.rs` — register handlers +- Create: `crates/stint-app/tests/idle_commands.rs` + +- [ ] **Step 1: Write the failing test** + +`crates/stint-app/tests/idle_commands.rs`: + +```rust +//! Integration test for idle_discard / idle_split. Exercises the verb +//! layer the way the Tauri commands would — same store + arguments. + +mod common; + +use stint_core::store::entries::Entries; +use stint_core::store::running::RunningTimer; + +#[tokio::test] +async fn idle_discard_stops_entry_at_idle_started() { + let env = common::setup().await; + + // Seed a running entry that started 30 minutes ago. + let start_at = "2026-05-27T10:00:00Z"; + let view = stint_core::verbs::start( + &env.store, + stint_core::verbs::StartParams { + description: "deep work".into(), + project_id: None, + task_id: None, + billable: false, + start_at: Some(start_at.into()), + source: "test".into(), + }, + ).await.unwrap(); + + let idle_started = "2026-05-27T10:18:00Z"; // user went idle 18 min in + + // Call the helper that backs the Tauri command (we expose it from + // crates/stint-app/src/commands/idle.rs). + stint_app::commands::idle::discard_impl(&env.store, idle_started).await.unwrap(); + + // Entry now has end_at == idle_started. + let row = Entries::new(env.store.clone()) + .get(&view.local_uuid).await.unwrap().unwrap(); + assert_eq!(row.end_at.as_deref(), Some(idle_started)); + + // Running timer is cleared. + let running = RunningTimer::new(env.store).get().await.unwrap(); + assert!(running.is_none()); +} + +#[tokio::test] +async fn idle_discard_errors_when_no_running_timer() { + let env = common::setup().await; + let result = stint_app::commands::idle::discard_impl( + &env.store, + "2026-05-27T10:00:00Z", + ).await; + assert!(matches!(result, Err(stint_core::Error::Invariant(_)))); +} +``` + +- [ ] **Step 2: Run, confirm fail** + +```bash +cargo test -p stint-app --test idle_commands -- --test-threads=1 2>&1 | tail -5 +``` + +Expected: compile error — `commands::idle::discard_impl` not found. + +- [ ] **Step 3: Implement `commands/idle.rs`** + +```rust +//! Tauri commands backing the IdleBanner.tsx buttons. The user gets: +//! Keep — banner dismisses; entry untouched. +//! Discard — end the entry at idle_started; subtract the idle period. +//! Split — same storage behavior as Discard; UI distinguishes by +//! pre-filling the start form for one-click resume. + +use stint_core::store::entries::Entries; +use stint_core::store::running::RunningTimer; +use stint_core::store::Store; +use stint_core::{Error, Result}; +use tauri::State; +use tokio::sync::RwLock; + +/// Pure backend helper — exposed so tests can exercise without going through +/// Tauri's runtime. +pub async fn discard_impl(store: &Store, idle_started: &str) -> Result<()> { + let running = RunningTimer::new(store.clone()) + .get().await? + .ok_or_else(|| Error::Invariant("no running timer".into()))?; + let entries = Entries::new(store.clone()); + entries.set_end(&running.local_uuid, idle_started).await?; + RunningTimer::new(store.clone()).clear().await?; + Ok(()) +} + +#[tauri::command] +pub async fn idle_keep() -> std::result::Result<(), String> { + // No-op; banner dismisses on its own. + Ok(()) +} + +#[tauri::command] +pub async fn idle_discard( + idle_started: String, + state: State<'_, RwLock>, +) -> std::result::Result<(), String> { + let store = state.read().await.store.clone(); + discard_impl(&store, &idle_started).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn idle_split( + idle_started: String, + state: State<'_, RwLock>, +) -> std::result::Result<(), String> { + // Same backend behavior as Discard. UI distinguishes by pre-filling + // the start form post-action. + let store = state.read().await.store.clone(); + discard_impl(&store, &idle_started).await.map_err(|e| e.to_string()) +} +``` + +Register in `crates/stint-app/src/commands/mod.rs`: + +```rust +pub mod idle; +``` + +Add to the `invoke_handler!` in main.rs (after the existing `commands::ui::show_main_window`): + +```rust +commands::idle::idle_keep, +commands::idle::idle_discard, +commands::idle::idle_split, +``` + +- [ ] **Step 4: Run, confirm pass** + +```bash +cargo test -p stint-app --test idle_commands -- --test-threads=1 2>&1 | tail -5 +``` + +Expected: 2 tests pass. + +- [ ] **Step 5: Commit** + +```bash +cargo fmt --all +cargo clippy -p stint-app --all-targets -- -D warnings +git add -A +git commit -m "feat(app): idle_keep/discard/split Tauri commands + +Three commands backing the IdleBanner UI. discard_impl is the shared +backend (set end_at on the running entry to the user's idle_started +timestamp + clear running_timer). Keep is a no-op; Split shares +backend with Discard (the 'restart now' UX is UI-only)." +``` + +--- + +## Task A6: IdleBanner.tsx UI + +**Goal:** SolidJS component that listens for the `idle:detected` Tauri event and shows three actions in the popover. + +**Files:** +- Create: `ui/src/components/IdleBanner.tsx` +- Modify: `ui/src/api.ts` — add three Tauri command wrappers +- Modify: `ui/src/routes/Today.tsx` — mount the banner + +- [ ] **Step 1: Add API wrappers in `ui/src/api.ts`** + +Look at the existing API shape (`api.start`, `api.stop`, etc.) and add: + +```ts +import { invoke } from "@tauri-apps/api/core"; + +export const api = { + // ... existing + idleKeep: () => invoke("idle_keep"), + idleDiscard: (idleStarted: string) => + invoke("idle_discard", { idleStarted }), + idleSplit: (idleStarted: string) => + invoke("idle_split", { idleStarted }), +}; +``` + +- [ ] **Step 2: Create `ui/src/components/IdleBanner.tsx`** + +```tsx +import { Show, createSignal, onCleanup, onMount } from "solid-js"; +import { listen } from "@tauri-apps/api/event"; +import { api } from "~/api"; + +interface IdleEvent { + idle_started: string; // ISO 8601 UTC + idle_secs: number; +} + +export default function IdleBanner(props: { onChange?: () => void }) { + const [event, setEvent] = createSignal(null); + const [busy, setBusy] = createSignal(false); + let dismissTimer: number | undefined; + + onMount(async () => { + const unlisten = await listen("idle:detected", (e) => { + setEvent(e.payload); + // Auto-snooze after 5 min — assume user moved on. + if (dismissTimer) window.clearTimeout(dismissTimer); + dismissTimer = window.setTimeout(() => setEvent(null), 5 * 60 * 1000); + }); + onCleanup(() => { + unlisten(); + if (dismissTimer) window.clearTimeout(dismissTimer); + }); + }); + + function fmtMinutes(secs: number): string { + const m = Math.round(secs / 60); + return `${m} minute${m === 1 ? "" : "s"}`; + } + + async function handleKeep() { + setBusy(true); + try { + await api.idleKeep(); + } finally { + setBusy(false); + setEvent(null); + } + } + + async function handleDiscard() { + const e = event(); + if (!e) return; + setBusy(true); + try { + await api.idleDiscard(e.idle_started); + props.onChange?.(); + } finally { + setBusy(false); + setEvent(null); + } + } + + async function handleSplit() { + const e = event(); + if (!e) return; + setBusy(true); + try { + await api.idleSplit(e.idle_started); + props.onChange?.(); + // TODO(6c.1): pre-fill the start form. For now, just close the + // existing entry — user manually starts a new one. + } finally { + setBusy(false); + setEvent(null); + } + } + + return ( + + {(e) => ( +
+
+ ⏸ You were idle for {fmtMinutes(e().idle_secs)} +
+
+ + + +
+
+ )} +
+ ); +} +``` + +- [ ] **Step 3: Mount in Today** + +In `ui/src/routes/Today.tsx`, import + mount above the TimerCard: + +```tsx +import IdleBanner from "~/components/IdleBanner"; +// ... inside the JSX, before : + refetch()} /> +``` + +- [ ] **Step 4: Typecheck + run UI tests** + +```bash +cd ui && pnpm typecheck 2>&1 | tail -5 +pnpm vitest run src/components 2>&1 | tail -5 +cd .. +``` + +Expected: clean typecheck, existing UI tests still green. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(ui): IdleBanner — listen for idle:detected + render 3 actions + +Mounts inside Today's popover layout above TimerCard. Listens for the +idle:detected Tauri event, shows the banner with Keep / Discard / +Discard+restart. Auto-snoozes after 5 min of being shown." +``` + +--- + +## Task A7: Idle settings UI + +**Goal:** Add an "Idle detection" section to Settings with the on/off toggle + threshold. + +**Files:** +- Modify: `ui/src/routes/Settings.tsx` — add section + +- [ ] **Step 1: Open Settings.tsx** + +```bash +grep -n "Section\| + Idle detection +
+ + +
+ +``` + +Wire up `idleEnabled()` / `setIdleEnabled` / `idleThreshold()` / `setIdleThreshold` via the existing `api.settingsGet` / `api.settingsSet` pattern (look at how other settings are persisted in Settings.tsx — there's likely a `createResource` + a debounced save). + +- [ ] **Step 3: Typecheck + commit** + +```bash +cd ui && pnpm typecheck 2>&1 | tail -3 +cd .. +git add -A +git commit -m "feat(ui): idle detection settings — toggle + threshold dropdown" +``` + +--- + +## Task B1: Raycast extension scaffold + +**Goal:** Set up the Raycast extension's TypeScript boilerplate. + +**Files:** +- Create: `raycast-stint/package.json` +- Create: `raycast-stint/tsconfig.json` +- Create: `raycast-stint/src/lib/stint.ts` — subprocess wrapper +- Create: `raycast-stint/src/lib/types.ts` — DTO types +- Create: `raycast-stint/README.md` +- Create: `raycast-stint/assets/icon.png` — placeholder; can be the same icon as Stint.app + +- [ ] **Step 1: Create package.json** + +```json +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "stint", + "title": "Stint", + "description": "Start, stop, and inspect Stint time entries from Raycast.", + "icon": "icon.png", + "author": "reyemtech", + "categories": ["Productivity"], + "license": "MIT", + "commands": [ + { "name": "start-timer", "title": "Start Timer", "description": "Start a new time entry", "mode": "view" }, + { "name": "stop-timer", "title": "Stop Timer", "description": "Stop the running timer", "mode": "no-view" }, + { "name": "current", "title": "Current Timer", "description": "Show the running timer", "mode": "view" }, + { "name": "recent-entries", "title": "Recent Entries", "description": "Browse and restart recent entries", "mode": "view" }, + { "name": "switch-project", "title": "Switch Project", "description": "Stop current and start on a different project", "mode": "view" } + ], + "preferences": [ + { + "name": "stintBin", + "type": "textfield", + "title": "Stint binary path", + "description": "Path to the stint CLI. Leave empty to auto-detect.", + "required": false, + "default": "" + } + ], + "dependencies": { + "@raycast/api": "^1.85.0", + "@raycast/utils": "^1.17.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "^22.0.0", + "@types/react": "^18.3.3", + "eslint": "^8.57.1", + "prettier": "^3.3.3", + "typescript": "^5.5.4" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop", + "lint": "ray lint", + "publish": "npx @raycast/api@latest publish" + } +} +``` + +- [ ] **Step 2: tsconfig.json** + +```json +{ + "$schema": "https://json.schemastore.org/tsconfig", + "include": ["src/**/*"], + "compilerOptions": { + "lib": ["ES2023"], + "module": "commonjs", + "target": "ES2022", + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "resolveJsonModule": true + } +} +``` + +- [ ] **Step 3: Subprocess wrapper `src/lib/stint.ts`** + +```ts +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { getPreferenceValues } from "@raycast/api"; + +const execFileAsync = promisify(execFile); + +interface Preferences { + stintBin: string; +} + +let cachedBinPath: string | null = null; + +function resolveBinPath(): string { + const pref = getPreferenceValues().stintBin?.trim(); + if (pref) return pref; + if (cachedBinPath) return cachedBinPath; + + // Discovery order: $PATH → ~/.cargo/bin → /Applications/Stint.app/Contents/MacOS + const candidates = [ + "/usr/local/bin/stint", + join(homedir(), ".cargo/bin/stint"), + "/Applications/Stint.app/Contents/MacOS/stint", + ]; + for (const path of candidates) { + if (existsSync(path)) { + cachedBinPath = path; + return path; + } + } + throw new Error( + "stint binary not found. Set the path in Raycast preferences.", + ); +} + +/// Invoke `stint --json ` and parse the JSON output. +export async function stint(...args: string[]): Promise { + const bin = resolveBinPath(); + const { stdout } = await execFileAsync(bin, ["--json", ...args], { + timeout: 10_000, + maxBuffer: 4 * 1024 * 1024, + }); + const trimmed = stdout.trim(); + if (!trimmed) return undefined as T; + return JSON.parse(trimmed) as T; +} +``` + +- [ ] **Step 4: DTO types `src/lib/types.ts`** + +```ts +export interface EntryDTO { + local_uuid: string; + solidtime_id: string | null; + description: string; + project_id: string | null; + task_id: string | null; + billable: boolean; + start_at: string; + end_at: string | null; + source: string; +} + +export interface ProjectDTO { + solidtime_id: string; + name: string; + color: string | null; + client_id: string | null; + archived: boolean; +} + +export interface TaskDTO { + solidtime_id: string; + project_id: string; + name: string; + done: boolean; +} +``` + +- [ ] **Step 5: Verify TypeScript compiles** + +```bash +cd raycast-stint && pnpm install --silent 2>&1 | tail -3 && npx tsc --noEmit 2>&1 | tail -5 +cd .. +``` + +Expected: clean (no Raycast SDK errors since we haven't imported it in lib yet). + +- [ ] **Step 6: README + icon placeholder + commit** + +`raycast-stint/README.md`: + +```markdown +# Stint for Raycast + +Five commands to drive [stint](https://github.com/reyemtech/stint) time +tracking from Raycast. + +## Install + +Until this is in the Raycast Store, install locally: + +1. Clone the stint repo. +2. From this directory, `pnpm install`. +3. In Raycast, run "Import Extension" and select the `raycast-stint/` + folder. + +## Configure + +The extension needs the `stint` CLI in your `PATH` or specified in +Raycast preferences. Default discovery order: + +- `/usr/local/bin/stint` +- `~/.cargo/bin/stint` +- `/Applications/Stint.app/Contents/MacOS/stint` + +## Commands + +- **Start Timer** — Form with description, project, task, billable +- **Stop Timer** — One-shot stop +- **Current Timer** — Inspect the running entry +- **Recent Entries** — Browse and restart +- **Switch Project** — Stop and start on a different project +``` + +Copy `crates/stint-app/icons/128x128.png` to `raycast-stint/assets/icon.png` as a placeholder. + +```bash +mkdir -p raycast-stint/assets +cp crates/stint-app/icons/128x128.png raycast-stint/assets/icon.png +git add raycast-stint/ +git commit -m "feat(raycast): scaffold raycast-stint extension package + +package.json declares 5 commands + stintBin preference. lib/stint.ts +wraps execFile around 'stint --json '; auto-discovers the +binary across /usr/local/bin, ~/.cargo/bin, and the bundled Stint.app +path. lib/types.ts mirrors the JSON shapes the CLI emits." +``` + +--- + +## Task B2: Raycast Start Timer command + +**Goal:** Form-based command that calls `stint --json start ...`. + +**Files:** +- Create: `raycast-stint/src/start-timer.tsx` + +- [ ] **Step 1: Create the command** + +```tsx +import { Form, ActionPanel, Action, Toast, showToast, popToRoot } from "@raycast/api"; +import { useState, useEffect } from "react"; +import { stint } from "./lib/stint"; +import type { ProjectDTO, TaskDTO, EntryDTO } from "./lib/types"; + +interface FormValues { + description: string; + project_id: string; + task_id: string; + billable: boolean; +} + +export default function Command() { + const [projects, setProjects] = useState([]); + const [tasks, setTasks] = useState([]); + const [selectedProject, setSelectedProject] = useState(""); + const [loadingProjects, setLoadingProjects] = useState(true); + const [loadingTasks, setLoadingTasks] = useState(false); + + useEffect(() => { + stint("projects", "list") + .then((list) => setProjects(list.filter((p) => !p.archived))) + .catch((e) => + showToast({ style: Toast.Style.Failure, title: "Failed to load projects", message: String(e) }), + ) + .finally(() => setLoadingProjects(false)); + }, []); + + useEffect(() => { + if (!selectedProject) { + setTasks([]); + return; + } + setLoadingTasks(true); + stint("projects", "list-tasks", selectedProject) + .then((list) => setTasks(list.filter((t) => !t.done))) + .catch(() => setTasks([])) + .finally(() => setLoadingTasks(false)); + }, [selectedProject]); + + async function handleSubmit(values: FormValues) { + try { + const args = ["start", "--description", values.description]; + if (values.project_id) args.push("--project", values.project_id); + if (values.task_id) args.push("--task", values.task_id); + if (values.billable) args.push("--billable"); + const entry = await stint(...args); + await showToast({ style: Toast.Style.Success, title: `Tracking '${entry.description}'` }); + await popToRoot(); + } catch (e) { + await showToast({ style: Toast.Style.Failure, title: "Failed to start timer", message: String(e) }); + } + } + + return ( +
+ + + } + > + + + + {projects.map((p) => ( + + ))} + + + + {tasks.map((t) => ( + + ))} + + + + ); +} +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +cd raycast-stint && npx tsc --noEmit 2>&1 | tail -5 +cd .. +git add raycast-stint/src/start-timer.tsx +git commit -m "feat(raycast): Start Timer command (form with project + task)" +``` + +--- + +## Task B3-B6: Remaining Raycast commands + +Same pattern as B2 — create one file per command. Show full code per command since they're each small and engineers reading the plan need each in isolation. + +### B3: `stop-timer.tsx` (no-view) + +- [ ] **Step 1: Create + commit** + +```tsx +import { showToast, Toast } from "@raycast/api"; +import { stint } from "./lib/stint"; +import type { EntryDTO } from "./lib/types"; + +export default async function Command() { + try { + const entry = await stint("stop"); + const start = new Date(entry.start_at); + const end = entry.end_at ? new Date(entry.end_at) : new Date(); + const mins = Math.round((end.getTime() - start.getTime()) / 60_000); + await showToast({ style: Toast.Style.Success, title: `Stopped (${mins}m)`, message: entry.description }); + } catch (e) { + await showToast({ style: Toast.Style.Failure, title: "Failed to stop", message: String(e) }); + } +} +``` + +```bash +git add raycast-stint/src/stop-timer.tsx +git commit -m "feat(raycast): Stop Timer command (no-view)" +``` + +### B4: `current.tsx` (Detail) + +- [ ] **Step 1: Create + commit** + +```tsx +import { Detail, ActionPanel, Action } from "@raycast/api"; +import { useEffect, useState } from "react"; +import { stint } from "./lib/stint"; +import type { EntryDTO } from "./lib/types"; + +export default function Command() { + const [entry, setEntry] = useState(null); + const [loading, setLoading] = useState(true); + + async function refresh() { + try { + const e = await stint("current"); + setEntry(e); + } finally { + setLoading(false); + } + } + + useEffect(() => { + refresh(); + const id = setInterval(refresh, 5000); + return () => clearInterval(id); + }, []); + + if (loading) return ; + if (!entry) return ; + + const start = new Date(entry.start_at); + const elapsedMins = Math.round((Date.now() - start.getTime()) / 60_000); + const md = `# ${entry.description || "(no description)"} + +**Project:** ${entry.project_id ?? "(none)"} +**Elapsed:** ${elapsedMins} minutes +**Billable:** ${entry.billable ? "yes" : "no"} +**Started:** ${start.toLocaleString()} +`; + + return ( + + + + } + /> + ); +} +``` + +```bash +git add raycast-stint/src/current.tsx +git commit -m "feat(raycast): Current Timer command (detail view, polls every 5s)" +``` + +### B5: `recent-entries.tsx` (List) + +- [ ] **Step 1: Create + commit** + +```tsx +import { List, ActionPanel, Action, showToast, Toast } from "@raycast/api"; +import { useEffect, useState } from "react"; +import { stint } from "./lib/stint"; +import type { EntryDTO } from "./lib/types"; + +export default function Command() { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + stint("list", "--limit", "50") + .then(setEntries) + .catch((e) => + showToast({ style: Toast.Style.Failure, title: "Failed", message: String(e) }), + ) + .finally(() => setLoading(false)); + }, []); + + async function handleRestart(entry: EntryDTO) { + try { + await stint("restart", entry.local_uuid); + await showToast({ style: Toast.Style.Success, title: `Restarted '${entry.description}'` }); + } catch (e) { + await showToast({ style: Toast.Style.Failure, title: "Restart failed", message: String(e) }); + } + } + + return ( + + {entries.map((e) => ( + + handleRestart(e)} /> + + + + } + /> + ))} + + ); +} +``` + +```bash +git add raycast-stint/src/recent-entries.tsx +git commit -m "feat(raycast): Recent Entries — browse + restart + copy + open in Stint" +``` + +### B6: `switch-project.tsx` (Form) + +- [ ] **Step 1: Create + commit** + +```tsx +import { Form, ActionPanel, Action, showToast, Toast, popToRoot } from "@raycast/api"; +import { useEffect, useState } from "react"; +import { stint } from "./lib/stint"; +import type { ProjectDTO, EntryDTO } from "./lib/types"; + +export default function Command() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [current, setCurrent] = useState(null); + + useEffect(() => { + Promise.all([ + stint("projects", "list"), + stint("current"), + ]) + .then(([p, c]) => { + setProjects(p.filter((x) => !x.archived)); + setCurrent(c); + }) + .catch((e) => + showToast({ style: Toast.Style.Failure, title: "Failed to load", message: String(e) }), + ) + .finally(() => setLoading(false)); + }, []); + + async function handleSubmit(values: { project_id: string }) { + if (!current) { + await showToast({ style: Toast.Style.Failure, title: "No timer to switch from" }); + return; + } + try { + await stint("stop"); + await stint( + "start", + "--description", + current.description, + "--project", + values.project_id, + ); + const proj = projects.find((p) => p.solidtime_id === values.project_id); + await showToast({ style: Toast.Style.Success, title: `Switched to ${proj?.name ?? values.project_id}` }); + await popToRoot(); + } catch (e) { + await showToast({ style: Toast.Style.Failure, title: "Switch failed", message: String(e) }); + } + } + + return ( +
+ + + } + > + + + {projects.map((p) => ( + + ))} + + + ); +} +``` + +```bash +git add raycast-stint/src/switch-project.tsx +git commit -m "feat(raycast): Switch Project — stop + start on new project preserving description" +``` + +--- + +## Task C1: Alfred workflow scaffold + +**Files:** +- Create: `alfred-stint/info.plist` +- Create: `alfred-stint/start.sh`, `stop.sh`, `current.sh`, `recent.sh` +- Create: `alfred-stint/icon.png` (copy from Stint.app) +- Create: `alfred-stint/README.md` + +- [ ] **Step 1: Helper script (shared binary discovery)** + +Create `alfred-stint/lib.sh`: + +```bash +#!/usr/bin/env bash +# Shared helpers for Stint Alfred workflow scripts. + +resolve_bin() { + if [[ -n "$STINT_BIN" ]] && [[ -x "$STINT_BIN" ]]; then + echo "$STINT_BIN" + return + fi + if command -v stint >/dev/null 2>&1; then + command -v stint + return + fi + for candidate in "$HOME/.cargo/bin/stint" "/Applications/Stint.app/Contents/MacOS/stint"; do + [[ -x "$candidate" ]] && { echo "$candidate"; return; } + done + return 1 +} +``` + +- [ ] **Step 2: start.sh** + +```bash +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/lib.sh" + +DESC="${1:?usage: start.sh }" +BIN="$(resolve_bin)" || { echo "Stint binary not found"; exit 1; } + +"$BIN" --json start --description "$DESC" | head -1 +``` + +- [ ] **Step 3: stop.sh** + +```bash +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/lib.sh" + +BIN="$(resolve_bin)" || { echo "Stint binary not found"; exit 1; } + +ENTRY="$("$BIN" --json stop)" +DESC="$(echo "$ENTRY" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("description",""))')" +echo "Stopped: $DESC" +``` + +- [ ] **Step 4: current.sh (Script Filter — emits Alfred items XML)** + +```bash +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/lib.sh" + +BIN="$(resolve_bin)" || { + cat </dev/null || echo "null")" +if [[ "$JSON" == "null" ]] || [[ -z "$JSON" ]]; then + echo '{"items":[{"title":"No active timer","valid":false}]}' + exit 0 +fi +python3 - </dev/null || echo "[]")" +python3 - < + + + + bundleid + tech.reyem.stint.alfred + name + Stint + description + Start, stop, and inspect Stint time entries from Alfred. + version + 0.1.0 + createdby + Reyem Technologies + readme + See README.md + + objects + + connections + + uidata + + + +``` + +The README documents the manual import step. + +- [ ] **Step 7: README + commit** + +`alfred-stint/README.md`: + +```markdown +# Stint for Alfred + +Four keyword shortcuts for [stint](https://github.com/reyemtech/stint): + +| Keyword | What it does | +|---|---| +| `s ` | Start a timer with that description | +| `sstop` | Stop the running timer | +| `scur` | Show the running timer | +| `srec` | List recent entries; ⏎ restarts, ⌥⏎ opens in Stint | + +## Install + +1. Double-click `Stint.alfredworkflow` from the GitHub Releases page. +2. Alfred prompts to import. +3. Make sure the `stint` CLI is in PATH (or set the Workflow Environment Variable `STINT_BIN`). + +## Build from source + +This directory IS the workflow source. Bundle: + +\`\`\`bash +zip -r Stint.alfredworkflow . -x ".*" +\`\`\` +``` + +```bash +chmod +x alfred-stint/*.sh +git add alfred-stint/ +git commit -m "feat(alfred): scaffold alfred-stint workflow + +Four scripts (start, stop, current, recent) sharing lib.sh for binary +discovery (STINT_BIN env > PATH > ~/.cargo/bin > /Applications/Stint.app). +info.plist is a minimal skeleton — the four keyword + script wiring is +done by the user post-import via Alfred's GUI; documented in README. + +Alfred's bundle format makes programmatic 'objects' wiring brittle; +the README + skeleton approach is what most Alfred extensions use." +``` + +--- + +## Task D1: WidgetKit Swift package scaffold + +**Goal:** Set up the StintWidget Swift Package and verify it builds as a static library (we'll wrap it as a `.appex` in a later task). + +**Files:** +- Create: `crates/stint-app/swift/StintWidget/Package.swift` +- Create: `crates/stint-app/swift/StintWidget/Sources/StintWidget/Stub.swift` +- Create: `crates/stint-app/swift/StintWidget/.gitignore` + +- [ ] **Step 1: Package.swift** + +```swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "StintWidget", + platforms: [.macOS(.v13)], + products: [ + .library(name: "StintWidget", type: .dynamic, targets: ["StintWidget"]), + ], + targets: [ + .target( + name: "StintWidget", + path: "Sources/StintWidget" + ), + ] +) +``` + +- [ ] **Step 2: Stub.swift (verifies the package builds)** + +```swift +import Foundation + +// Placeholder so the target has at least one source file before we add +// the real widget code in subsequent tasks. +struct StintWidgetVersion { + static let current = "0.1.0" +} +``` + +- [ ] **Step 3: .gitignore** + +``` +.build/ +build/ +.swiftpm/ +``` + +- [ ] **Step 4: Build via xcodebuild** + +```bash +cd crates/stint-app/swift/StintWidget +xcodebuild -scheme StintWidget -configuration Release -destination 'platform=macOS' -derivedDataPath ./build/derived build 2>&1 | tail -3 +cd - +``` + +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/stint-app/swift/StintWidget/ +git commit -m "feat(widget): scaffold StintWidget Swift package + +Empty Package.swift + Stub.swift to verify the SPM target builds. Real +widget code (WidgetConfigurationIntent, TimelineProvider, SwiftUI views) +lands in the following tasks." +``` + +--- + +## Task D2: PortDiscovery + DTO coding + +**Goal:** Swift code that reads `~/Library/Application Support/stint/api.port` + decodes the HTTP JSON shapes. Unit-testable without touching live HTTP. + +**Files:** +- Create: `Sources/StintWidget/Models/PortDiscovery.swift` +- Create: `Sources/StintWidget/Models/EntryDTO.swift` +- Create: `Sources/StintWidget/Models/ProjectDTO.swift` +- Create: `Tests/StintWidgetTests/PortDiscoveryTests.swift` +- Create: `Tests/StintWidgetTests/DTOCodingTests.swift` +- Modify: `Package.swift` — add testTarget + +- [ ] **Step 1: Models** + +`Sources/StintWidget/Models/PortDiscovery.swift`: + +```swift +import Foundation + +enum PortDiscoveryError: Error { + case fileNotFound + case unreadable + case parseError +} + +struct PortDiscovery { + /// `~/Library/Application Support/stint/api.port` per spec §6.4. + static var defaultPath: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("stint/api.port") + } + + static func read(from url: URL = defaultPath) throws -> UInt16 { + guard FileManager.default.fileExists(atPath: url.path) else { throw PortDiscoveryError.fileNotFound } + guard let data = try? Data(contentsOf: url), + let s = String(data: data, encoding: .utf8) else { throw PortDiscoveryError.unreadable } + guard let port = UInt16(s.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw PortDiscoveryError.parseError + } + return port + } +} +``` + +`Sources/StintWidget/Models/EntryDTO.swift`: + +```swift +import Foundation + +struct EntryDTO: Codable { + let local_uuid: String + let solidtime_id: String? + let description: String + let project_id: String? + let task_id: String? + let billable: Bool + let start_at: String // ISO 8601 UTC + let end_at: String? + let source: String +} +``` + +`Sources/StintWidget/Models/ProjectDTO.swift`: + +```swift +import Foundation + +struct ProjectDTO: Codable { + let solidtime_id: String + let name: String + let color: String? + let client_id: String? + let archived: Bool +} +``` + +- [ ] **Step 2: Tests** + +`Tests/StintWidgetTests/PortDiscoveryTests.swift`: + +```swift +import Testing +import Foundation +@testable import StintWidget + +@Suite("PortDiscovery") +struct PortDiscoveryTests { + @Test func readsValidPortFile() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("port-\(UUID()).txt") + try "49792\n".write(to: tmp, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tmp) } + let port = try PortDiscovery.read(from: tmp) + #expect(port == 49792) + } + + @Test func errorsWhenFileMissing() { + let nowhere = URL(fileURLWithPath: "/tmp/does-not-exist-\(UUID()).port") + #expect(throws: PortDiscoveryError.self) { + try PortDiscovery.read(from: nowhere) + } + } + + @Test func errorsOnGarbledFile() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("bad-\(UUID()).txt") + try "not-a-number".write(to: tmp, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tmp) } + #expect(throws: PortDiscoveryError.self) { + try PortDiscovery.read(from: tmp) + } + } +} +``` + +`Tests/StintWidgetTests/DTOCodingTests.swift`: + +```swift +import Testing +import Foundation +@testable import StintWidget + +@Suite("DTO Coding") +struct DTOCodingTests { + @Test func entryDecodes() throws { + let json = #"{"local_uuid":"u1","solidtime_id":null,"description":"x","project_id":"p1","task_id":null,"billable":false,"start_at":"2026-05-27T10:00:00Z","end_at":null,"source":"test"}"# + let dto = try JSONDecoder().decode(EntryDTO.self, from: Data(json.utf8)) + #expect(dto.local_uuid == "u1") + #expect(dto.description == "x") + } + + @Test func projectDecodes() throws { + let json = #"{"solidtime_id":"p1","name":"Acme","color":null,"client_id":null,"archived":false}"# + let dto = try JSONDecoder().decode(ProjectDTO.self, from: Data(json.utf8)) + #expect(dto.name == "Acme") + } +} +``` + +- [ ] **Step 3: Add testTarget to Package.swift** + +```swift +targets: [ + .target(name: "StintWidget", path: "Sources/StintWidget"), + .testTarget( + name: "StintWidgetTests", + dependencies: ["StintWidget"], + path: "Tests/StintWidgetTests" + ), +] +``` + +- [ ] **Step 4: Run tests** + +```bash +cd crates/stint-app/swift/StintWidget +xcodebuild -scheme StintWidget -destination 'platform=macOS' -derivedDataPath ./build/derived test 2>&1 | tail -10 +cd - +``` + +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/stint-app/swift/StintWidget/ +git commit -m "feat(widget): PortDiscovery + DTO coding + tests + +PortDiscovery reads ~/Library/Application Support/stint/api.port and +returns a UInt16; throws typed errors for missing/garbled files. +EntryDTO + ProjectDTO mirror the Rust serde shapes used by the HTTP +API. 5 tests cover happy paths + the three error modes." +``` + +--- + +## Task D3: TimelineProvider + HTTP fetch + +**Goal:** Swift TimelineProvider that fetches `/v1/current` over loopback HTTP and builds entries. + +**Files:** +- Create: `Sources/StintWidget/Provider.swift` + +- [ ] **Step 1: Implement Provider** + +```swift +import WidgetKit +import Foundation + +struct StintTimelineEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot +} + +enum WidgetSnapshot { + case unavailable // stint not running / port unreadable + case runningTimer(description: String, projectName: String?, elapsedSecs: TimeInterval) + case idleTimer + case todayTotal(seconds: TimeInterval, byProject: [(name: String, seconds: TimeInterval)]) + case weekProject(projectName: String, seconds: TimeInterval, byDay: [TimeInterval]) +} + +struct StintProvider: TimelineProvider { + func placeholder(in context: Context) -> StintTimelineEntry { + StintTimelineEntry(date: Date(), snapshot: .runningTimer(description: "Loading…", projectName: nil, elapsedSecs: 0)) + } + + func getSnapshot(in context: Context, completion: @escaping (StintTimelineEntry) -> Void) { + Task { + let entry = await fetchOne() + completion(entry) + } + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + Task { + let snapshot = await fetchSnapshot() + let now = Date() + switch snapshot { + case .runningTimer: + // 60 entries at 1-minute intervals so the elapsed clock stays + // up-to-date without us calling getTimeline too often. + var entries: [StintTimelineEntry] = [] + for i in 0..<60 { + entries.append(StintTimelineEntry(date: now.addingTimeInterval(TimeInterval(i * 60)), snapshot: snapshot)) + } + completion(Timeline(entries: entries, policy: .atEnd)) + default: + // Static snapshots — refresh every 5 minutes. + completion(Timeline(entries: [StintTimelineEntry(date: now, snapshot: snapshot)], policy: .after(now.addingTimeInterval(300)))) + } + } + } + + // ---- HTTP fetch ---- + + private func fetchSnapshot() async -> WidgetSnapshot { + guard let port = try? PortDiscovery.read() else { return .unavailable } + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/v1/current")!) + request.timeoutInterval = 2 + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + return .unavailable + } + // /v1/current returns EntryDTO or "null" + if data.count <= 4, let str = String(data: data, encoding: .utf8), str.trimmingCharacters(in: .whitespacesAndNewlines) == "null" { + return .idleTimer + } + let entry = try JSONDecoder().decode(EntryDTO.self, from: data) + let start = ISO8601DateFormatter().date(from: entry.start_at) ?? Date() + return .runningTimer( + description: entry.description, + projectName: entry.project_id, + elapsedSecs: Date().timeIntervalSince(start) + ) + } catch { + return .unavailable + } + } + + private func fetchOne() async -> StintTimelineEntry { + StintTimelineEntry(date: Date(), snapshot: await fetchSnapshot()) + } +} +``` + +- [ ] **Step 2: Build to verify compile** + +```bash +cd crates/stint-app/swift/StintWidget +xcodebuild -scheme StintWidget -configuration Release -destination 'platform=macOS' -derivedDataPath ./build/derived build 2>&1 | tail -5 +cd - +``` + +Expected: BUILD SUCCEEDED. WidgetKit / Foundation symbols all resolved. + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/swift/StintWidget/Sources/StintWidget/Provider.swift +git commit -m "feat(widget): TimelineProvider + HTTP fetch via PortDiscovery + +StintProvider implements WidgetKit's TimelineProvider — placeholder / +snapshot / timeline. Fetches via URLSession against http://127.0.0.1:/v1/current +with a 2s timeout. Running timer kind produces 60 1-minute timeline +entries (policy: .atEnd); other kinds get a single entry with .after(5m). + +Snapshots are enum-based (.unavailable / .runningTimer / .idleTimer / +.todayTotal / .weekProject) so Views can switch over them cleanly in +later tasks. Today/week kinds and their HTTP fetches come in the +matching View task — the placeholder + running-timer path here is +enough to verify the wiring before the visual work." +``` + +--- + +## Task D4: SwiftUI Views (3 kinds × 2 sizes) + +**Goal:** Render snapshots into SwiftUI views per kind/size. + +**Files:** +- Create: `Sources/StintWidget/Views/RunningTimerView.swift` +- Create: `Sources/StintWidget/Views/TodayTotalView.swift` +- Create: `Sources/StintWidget/Views/WeekProjectView.swift` + +Each view is small (~30 lines). One task per view; the pattern is the same. + +- [ ] **Step 1: RunningTimerView.swift** + +```swift +import SwiftUI +import WidgetKit + +struct RunningTimerView: View { + let snapshot: WidgetSnapshot + let size: WidgetFamily + + var body: some View { + switch snapshot { + case .runningTimer(let desc, let proj, let elapsed): + VStack(alignment: .leading, spacing: 4) { + Text(timeString(elapsed)) + .font(.system(size: size == .systemSmall ? 28 : 36, weight: .semibold, design: .rounded)) + .monospacedDigit() + Text(desc).font(.callout).lineLimit(size == .systemSmall ? 1 : 2) + if let p = proj { + Text(p).font(.caption).foregroundStyle(.secondary) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + case .idleTimer: + VStack(alignment: .leading, spacing: 4) { + Text("No active timer").font(.callout) + Text("Tap to open Stint").font(.caption).foregroundStyle(.secondary) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + case .unavailable: + VStack(alignment: .leading, spacing: 4) { + Text("Stint not running").font(.callout) + Text("Launch the app and re-try").font(.caption).foregroundStyle(.secondary) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + default: + EmptyView() + } + } + + private func timeString(_ secs: TimeInterval) -> String { + let total = Int(secs) + let h = total / 3600 + let m = (total % 3600) / 60 + return String(format: "%d:%02d", h, m) + } +} +``` + +- [ ] **Step 2: TodayTotalView.swift** (similar shape — show total hours + top-3 project breakdown for medium) + +```swift +import SwiftUI +import WidgetKit + +struct TodayTotalView: View { + let snapshot: WidgetSnapshot + let size: WidgetFamily + + var body: some View { + switch snapshot { + case .todayTotal(let total, let byProject): + VStack(alignment: .leading, spacing: 6) { + Text(timeString(total)) + .font(.system(size: 32, weight: .semibold, design: .rounded)) + .monospacedDigit() + Text("Today").font(.caption).foregroundStyle(.secondary) + if size == .systemMedium { + ForEach(byProject.prefix(3), id: \.name) { item in + HStack { + Text(item.name).font(.caption).lineLimit(1) + Spacer() + Text(timeString(item.seconds)).font(.caption).monospacedDigit() + } + } + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + default: + EmptyView() + } + } + + private func timeString(_ secs: TimeInterval) -> String { + let total = Int(secs) + let h = total / 3600 + let m = (total % 3600) / 60 + return "\(h)h \(m)m" + } +} +``` + +- [ ] **Step 3: WeekProjectView.swift** — small: hours number. Medium: 7 bars. + +```swift +import SwiftUI +import WidgetKit + +struct WeekProjectView: View { + let snapshot: WidgetSnapshot + let size: WidgetFamily + + var body: some View { + switch snapshot { + case .weekProject(let projectName, let total, let byDay): + VStack(alignment: .leading, spacing: 6) { + Text(projectName).font(.caption).foregroundStyle(.secondary).lineLimit(1) + Text(timeString(total)) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .monospacedDigit() + if size == .systemMedium { + BarChart(values: byDay) + .frame(height: 40) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + default: + EmptyView() + } + } + + private func timeString(_ secs: TimeInterval) -> String { + let total = Int(secs) + let h = total / 3600 + let m = (total % 3600) / 60 + return "\(h)h \(m)m" + } +} + +struct BarChart: View { + let values: [TimeInterval] + var body: some View { + GeometryReader { geo in + let maxVal = values.max() ?? 1 + HStack(alignment: .bottom, spacing: 2) { + ForEach(values.indices, id: \.self) { i in + Rectangle() + .fill(Color.accentColor) + .frame(width: (geo.size.width - CGFloat(values.count - 1) * 2) / CGFloat(values.count), + height: max(2, geo.size.height * CGFloat(values[i] / maxVal))) + } + } + } + } +} +``` + +- [ ] **Step 4: Build, commit** + +```bash +cd crates/stint-app/swift/StintWidget +xcodebuild -scheme StintWidget -configuration Release -destination 'platform=macOS' -derivedDataPath ./build/derived build 2>&1 | tail -3 +cd - +git add crates/stint-app/swift/StintWidget/Sources/StintWidget/Views/ +git commit -m "feat(widget): SwiftUI views for 3 widget kinds × 2 sizes + +RunningTimerView / TodayTotalView / WeekProjectView. Each switches over +WidgetSnapshot variants; Small kept compact (just the key number), +Medium adds extra context (project breakdown, day-by-day bar chart). +BarChart is a thin custom GeometryReader-based component (no +SwiftUI Charts dependency — not available pre-macOS-13)." +``` + +--- + +## Task D5: WidgetConfigurationIntent + Widget declaration + +**Files:** +- Create: `Sources/StintWidget/WidgetConfigIntent.swift` +- Create: `Sources/StintWidget/RunningTimerWidget.swift` +- Create: `Sources/StintWidget/StintWidgetBundle.swift` + +- [ ] **Step 1: WidgetConfigIntent.swift** + +```swift +import AppIntents +import WidgetKit + +enum WidgetKind: String, AppEnum, CaseIterable { + case runningTimer + case todayTotal + case weekProject + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Stint widget type" + + static var caseDisplayRepresentations: [WidgetKind : DisplayRepresentation] = [ + .runningTimer: "Running Timer", + .todayTotal: "Today Total", + .weekProject: "This-Week Project", + ] +} + +// Minimal Project entity for the widget config sheet. Distinct from the +// StintIntents.framework ProjectEntity (different binary, different module). +// Loaded via a small HTTP fetch in the entity query. +struct WidgetProjectEntity: AppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Project" + static var defaultQuery = WidgetProjectQuery() + + let id: String + let name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +struct WidgetProjectQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [WidgetProjectEntity] { + let all = try await fetchProjects() + return all.filter { identifiers.contains($0.id) } + } + func suggestedEntities() async throws -> [WidgetProjectEntity] { + try await fetchProjects() + } + + private func fetchProjects() async throws -> [WidgetProjectEntity] { + let port = try PortDiscovery.read() + let url = URL(string: "http://127.0.0.1:\(port)/v1/projects")! + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode([ProjectDTO].self, from: data) + .filter { !$0.archived } + .map { WidgetProjectEntity(id: $0.solidtime_id, name: $0.name) } + } +} + +struct WidgetConfigIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Configure Stint Widget" + + @Parameter(title: "Show", default: .runningTimer) + var kind: WidgetKind + + @Parameter(title: "Project") + var project: WidgetProjectEntity? +} +``` + +- [ ] **Step 2: RunningTimerWidget.swift** + +```swift +import WidgetKit +import SwiftUI + +struct RunningTimerWidget: Widget { + let kind: String = "tech.reyem.stint.widget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: WidgetConfigIntent.self, provider: StintProvider()) { entry in + WidgetRenderer(snapshot: entry.snapshot) + } + .configurationDisplayName("Stint") + .description("Time-tracking dashboard for stint.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +/// Dispatches to the right view for the snapshot. +struct WidgetRenderer: View { + let snapshot: WidgetSnapshot + @Environment(\.widgetFamily) var family + + var body: some View { + switch snapshot { + case .runningTimer, .idleTimer, .unavailable: + RunningTimerView(snapshot: snapshot, size: family) + case .todayTotal: + TodayTotalView(snapshot: snapshot, size: family) + case .weekProject: + WeekProjectView(snapshot: snapshot, size: family) + } + } +} +``` + +- [ ] **Step 3: StintWidgetBundle.swift (@main)** + +```swift +import WidgetKit +import SwiftUI + +@main +struct StintWidgetBundle: WidgetBundle { + var body: some Widget { + RunningTimerWidget() + } +} +``` + +- [ ] **Step 4: Build to verify** + +```bash +cd crates/stint-app/swift/StintWidget +xcodebuild -scheme StintWidget -configuration Release -destination 'platform=macOS' -derivedDataPath ./build/derived build 2>&1 | tail -5 +cd - +``` + +Expected: BUILD SUCCEEDED. + +- [ ] **Step 5: Commit** + +```bash +git add crates/stint-app/swift/StintWidget/Sources/StintWidget/{WidgetConfigIntent.swift,RunningTimerWidget.swift,StintWidgetBundle.swift} +git commit -m "feat(widget): WidgetConfigurationIntent + Widget declaration + @main bundle + +WidgetConfigIntent declares 'kind' (enum) + 'project' (entity, only +matters for .weekProject). WidgetProjectQuery loads choices via the +loopback HTTP API. + +AppIntentConfiguration ties the intent to the StintProvider; the +configuration sheet renders inline in the widget gallery (no +siriactionsd / Shortcuts.app discovery involved — different code path +from the deferred App Intents work in 6b.1). + +Bundle declares @main + a single Widget — minimum viable .appex +manifest. Supported families: systemSmall + systemMedium." +``` + +--- + +## Task D6: build.rs xcodebuild integration + +**Goal:** stint-app's build.rs invokes xcodebuild on the StintWidget package and places the resulting `.appex` at `crates/stint-app/PlugIns/StintWidget.appex/` for Tauri to consume. + +**Files:** +- Modify: `crates/stint-app/build.rs` + +- [ ] **Step 1: Extend build.rs** + +Add a new function `build_stint_widget()` that mirrors `build_stint_intents_framework()` but produces `.appex`. xcodebuild on the Swift Widget package emits a `.appex` under `Build/Products/Release/PackageFrameworks/StintWidget.framework` — we need to repackage it as a `.appex` (different Info.plist + extension point identifier). + +A simpler approach: in `Package.swift`, set the product to a `.dynamic` library; then build.rs copies + re-wraps as a `.appex` bundle. The `.appex` is just a directory with a specific Info.plist (`NSExtension` dict declaring `com.apple.widgetkit-extension` point) and the binary. + +```rust +fn build_stint_widget() -> Result<(), String> { + if env::var_os("STINT_SKIP_SWIFT_BUILD").is_some_and(|v| !v.is_empty()) { + return Err("STINT_SKIP_SWIFT_BUILD is set".into()); + } + // ... mirror build_stint_intents_framework's xcodebuild invocation but + // -scheme StintWidget. Output dir: crates/stint-app/PlugIns/StintWidget.appex/ + + let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?; + let swift_dir = Path::new(&manifest_dir).join("swift/StintWidget"); + // ... xcodebuild same as before, derivedDataPath = swift_dir.join("build/derived") + // ... built .framework at Build/Products/Release/PackageFrameworks/StintWidget.framework + + let built = swift_dir.join("build/derived/Build/Products/Release/PackageFrameworks/StintWidget.framework"); + let dest = Path::new(&manifest_dir).join("PlugIns/StintWidget.appex"); + let _ = fs::remove_dir_all(&dest); + fs::create_dir_all(dest.join("Contents/MacOS")).map_err(|e| format!("create dirs: {e}"))?; + + // Copy the dylib (renamed to StintWidget) into Contents/MacOS/ + let dylib = built.join("Versions/A/StintWidget"); + fs::copy(&dylib, dest.join("Contents/MacOS/StintWidget")) + .map_err(|e| format!("copy dylib: {e}"))?; + + // Write a proper .appex Info.plist + let info_plist = format!(r#" + + + + CFBundleIdentifier + tech.reyem.stint.widget + CFBundleExecutable + StintWidget + CFBundleName + StintWidget + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + XPC! + LSMinimumSystemVersion + 13.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + +"#); + fs::write(dest.join("Contents/Info.plist"), info_plist) + .map_err(|e| format!("write Info.plist: {e}"))?; + + // Copy the Metadata.appintents stencil (WidgetConfigIntent) if present + let stencil = swift_dir.join("build/derived/Build/Products/Release/StintWidget.appintents/Metadata.appintents"); + if stencil.exists() { + let dst = dest.join("Contents/Resources/Metadata.appintents"); + let _ = fs::remove_dir_all(&dst); + copy_dir(&stencil, &dst).map_err(|e| format!("copy stencil: {e}"))?; + } + + codesign_adhoc(&dest).map_err(|e| format!("codesign appex: {e}"))?; + + println!("cargo:warning=StintWidget.appex rebuilt at {}", dest.display()); + Ok(()) +} +``` + +Add to `main()`: + +```rust +fn main() { + if let Err(e) = build_stint_intents_framework() { ... } + if let Err(e) = build_stint_widget() { + println!("cargo:warning=StintWidget build skipped: {e}"); + } + tauri_build::build() +} +``` + +- [ ] **Step 2: Build, verify the .appex exists** + +```bash +cargo build -p stint-app 2>&1 | tail -5 +ls crates/stint-app/PlugIns/StintWidget.appex/Contents/ +``` + +Expected: `Info.plist`, `MacOS/`, `Resources/Metadata.appintents` (the configuration intent stencil). + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/build.rs +git commit -m "chore(build): stint-app build.rs produces StintWidget.appex + +xcodebuild against the StintWidget Swift package (parallel to the +existing StintIntents framework build). The output framework gets +repackaged as a proper .appex bundle: + + Contents/Info.plist — NSExtension point com.apple.widgetkit-extension + Contents/MacOS/StintWidget — the dylib + Contents/Resources/Metadata.appintents — WidgetConfigIntent stencil + +Ad-hoc signed; release CI re-signs with the real Developer ID." +``` + +--- + +## Task D7: Tauri bundle.resources for .appex + +**Goal:** Tauri's bundle step copies the `.appex` into `Stint.app/Contents/PlugIns/StintWidget.appex/`. + +**Files:** +- Modify: `crates/stint-app/tauri.conf.json` + +- [ ] **Step 1: Add resources entries** + +Tauri's `bundle.resources` maps source paths → bundle-relative destinations. List every file inside the `.appex` (it's small; 5-7 files). Order in JSON doesn't matter; paths are resolved at bundle time. + +```json +"resources": { + "resources/man1/stint.1": "man/man1/stint.1", + "Frameworks/StintIntents.framework/Versions/A/Resources/Metadata.appintents/version.json": "Metadata.appintents/version.json", + "Frameworks/StintIntents.framework/Versions/A/Resources/Metadata.appintents/extract.actionsdata": "Metadata.appintents/extract.actionsdata", + "PlugIns/StintWidget.appex/Contents/Info.plist": "PlugIns/StintWidget.appex/Contents/Info.plist", + "PlugIns/StintWidget.appex/Contents/MacOS/StintWidget": "PlugIns/StintWidget.appex/Contents/MacOS/StintWidget", + "PlugIns/StintWidget.appex/Contents/Resources/Metadata.appintents/version.json": "PlugIns/StintWidget.appex/Contents/Resources/Metadata.appintents/version.json", + "PlugIns/StintWidget.appex/Contents/Resources/Metadata.appintents/extract.actionsdata": "PlugIns/StintWidget.appex/Contents/Resources/Metadata.appintents/extract.actionsdata" +}, +``` + +- [ ] **Step 2: cargo tauri build + verify** + +```bash +cd crates/stint-app && cargo tauri build --bundles app 2>&1 | tail -3 +cd - +ls target/release/bundle/macos/Stint.app/Contents/PlugIns/StintWidget.appex/Contents/ +``` + +Expected: `Info.plist`, `MacOS/`, `Resources/Metadata.appintents/`. + +If Tauri rejects the `.appex` paths (some versions don't allow `PlugIns/` prefix), fallback: add a post-build step that copies the `.appex` directly: + +```bash +cp -R crates/stint-app/PlugIns/StintWidget.appex target/release/bundle/macos/Stint.app/Contents/PlugIns/ +``` + +Document the chosen path in the commit message. + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/tauri.conf.json +git commit -m "chore(app): bundle StintWidget.appex into Stint.app/Contents/PlugIns/ + +Adds bundle.resources entries mapping each file in +crates/stint-app/PlugIns/StintWidget.appex/ to its Stint.app +counterpart at Contents/PlugIns/StintWidget.appex/. macOS scans +Contents/PlugIns/ for .appex bundles at install time to register +extension points (here: widgetkit-extension)." +``` + +--- + +## Task D8: Sign + smoke + verify in /Applications + +**Goal:** Full bundle sign + install + verify the widget shows up in macOS's widget gallery. + +- [ ] **Step 1: Sign the bundle** + +```bash +IDENTITY="Developer ID Application: Reyem Technologies Inc. (WAK5K2758P)" +APP="target/release/bundle/macos/Stint.app" +ENTITLEMENTS="crates/stint-app/entitlements.plist" +FRAMEWORK="$APP/Contents/Frameworks/StintIntents.framework" +APPEX="$APP/Contents/PlugIns/StintWidget.appex" + +codesign --force --options runtime --sign "$IDENTITY" "$FRAMEWORK" 2>&1 | tail -1 +codesign --force --options runtime --sign "$IDENTITY" "$APPEX" 2>&1 | tail -1 +codesign --force --options runtime --sign "$IDENTITY" --entitlements "$ENTITLEMENTS" "$APP/Contents/MacOS/stint-app" 2>&1 | tail -1 +codesign --force --options runtime --sign "$IDENTITY" --entitlements "$ENTITLEMENTS" "$APP" 2>&1 | tail -1 +codesign --verify --deep --strict --verbose=2 "$APP" 2>&1 | tail -2 +``` + +Expected: `valid on disk` + `satisfies its Designated Requirement`. + +- [ ] **Step 2: Notarize** + +```bash +ZIP="${APP}.zip" ; rm -f "$ZIP" ; ditto -c -k --keepParent "$APP" "$ZIP" +xcrun notarytool submit "$ZIP" --keychain-profile "stint-notary" --wait 2>&1 | tail -3 +xcrun stapler staple "$APP" 2>&1 | tail -1 +``` + +Expected: `status: Accepted` and staple confirms. + +- [ ] **Step 3: Install + verify** + +```bash +killall stint-app 2>/dev/null ; sleep 1 +rm -rf /Applications/Stint.app +cp -R "$APP" /Applications/ +xattr -cr /Applications/Stint.app +/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f /Applications/Stint.app +open /Applications/Stint.app +sleep 5 +ls /Applications/Stint.app/Contents/PlugIns/StintWidget.appex/Contents/MacOS/ +``` + +Expected: `StintWidget` binary present. + +- [ ] **Step 4: Manual: add the widget** + +1. On the desktop, **Right-click** → **Edit Widgets**. +2. Search for "Stint". +3. Expect the Stint widget to appear under the Apps list with three configuration options (Running Timer / Today Total / This-Week Project) and two sizes (small / medium). +4. Pick Running Timer Small → drag onto the desktop. +5. The widget should show the current timer (or "No active timer" placeholder). + +If the widget doesn't appear in the gallery → check Console.app for `widgetkit` log entries while running `pluginkit -mvD | grep -i stint.widget`. Common issue: `.appex` Info.plist's `NSExtensionPointIdentifier` typo. + +- [ ] **Step 5: Commit verification notes** + +```bash +git commit --allow-empty -m "test(widget): manual smoke — widget appears in macOS gallery + renders snapshot" +``` + +(Empty commit to mark the milestone in history; no source change.) + +--- + +## Task E1: Widget-presence-aware HTTP auto-enable + +**Goal:** When stint-app starts and detects ≥1 stint widget installed, auto-flip `api.enabled = true`. + +**Files:** +- Modify: `crates/stint-app/src/main.rs` — call a new helper from setup +- Create: `crates/stint-app/swift/StintWidget/Sources/StintWidget/WidgetCount.swift` — @_cdecl helper +- Modify: `crates/stint-app/src/idle_detector.rs` (or a new `widget_presence.rs`) — Rust side that dlsyms into Swift + +- [ ] **Step 1: Swift @_cdecl helper** + +`Sources/StintWidget/WidgetCount.swift`: + +```swift +import Foundation +import WidgetKit + +@_cdecl("stint_widget_count") +public func stint_widget_count() -> Int32 { + // Returns count of currently-configured Stint widgets, or -1 on error. + let kindFilter = "tech.reyem.stint.widget" + let semaphore = DispatchSemaphore(value: 0) + var result: Int32 = -1 + WidgetCenter.shared.getCurrentConfigurations { res in + if case .success(let widgets) = res { + result = Int32(widgets.filter { $0.kind == kindFilter }.count) + } + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + .seconds(2)) + return result +} +``` + +- [ ] **Step 2: Rust side** + +In `crates/stint-app/src/main.rs`, add: + +```rust +async fn auto_enable_api_if_widgets_present(store: &Store) { + extern "C" { + fn stint_widget_count() -> i32; + } + let count = unsafe { + let name = std::ffi::CString::new("stint_widget_count").unwrap(); + let sym = libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr()); + if sym.is_null() { + -1 + } else { + let f: extern "C" fn() -> i32 = std::mem::transmute(sym); + f() + } + }; + if count <= 0 { return; } + let settings = stint_core::config::Settings::new(store.clone()); + let enabled: Option = settings.get("api.enabled").await.unwrap_or(None); + let is_on = matches!(enabled.as_deref(), Some("true")); + if !is_on { + let _ = settings.set("api.enabled", "true").await; + tracing::info!("auto-enabled api.enabled because {count} widgets are configured"); + } +} +``` + +Call from `setup()` after the framework init: + +```rust +{ + let store_for_widget_check = store_for_worker.clone(); + tokio::spawn(async move { + auto_enable_api_if_widgets_present(&store_for_widget_check).await; + }); +} +``` + +- [ ] **Step 3: Build + commit** + +```bash +cargo build -p stint-app 2>&1 | tail -3 +git add -A +git commit -m "feat(app): auto-enable api.enabled when stint widgets are configured + +Calls stint_widget_count (Swift @_cdecl in StintWidget.appex) via +dlsym at setup. If ≥1 widget is configured AND api.enabled is false, +flip it to true and persist. The widget needs the HTTP API to serve +its data; auto-enabling removes the 'why is my widget showing 'Stint +not running'?' onboarding friction. + +dlsym returns null if the .appex isn't loaded (CLI binary, dev build +without bundling) — call no-ops gracefully." +``` + +--- + +## Task E2: SKILL.md + README + CLAUDE.md updates + +**Files:** +- Modify: `crates/stint-cli/skills/stint/SKILL.md` +- Modify: `README.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: SKILL.md — add 6c surfaces** + +Append to the "Bonus surfaces (Phase 6b)" section (renaming the section heading to "Bonus surfaces (Phases 6b + 6c)"): + +```markdown +- **Raycast extension** (Phase 6c live): five commands — Start Timer, Stop, Current, Recent Entries, Switch Project. Install via Import Extension from `raycast-stint/` until the Raycast Store listing lands. +- **Alfred workflow** (Phase 6c live): keywords `s ` (start), `sstop`, `scur`, `srec`. Install via the .alfredworkflow bundle from GitHub Releases. +- **WidgetKit widget** (Phase 6c live): per-instance configurable. Three kinds (Running Timer, Today Total, This-Week Project) × two sizes (small, medium). Auto-enables the loopback HTTP API on first widget install. +- **Idle detection** (Phase 6c live): When a timer is running and you've been idle ≥10 minutes (configurable in Settings), a banner offers to Keep, Discard, or Discard+restart. Threshold is `idle.threshold_secs` (default 600). +``` + +- [ ] **Step 2: README.md and CLAUDE.md roadmap rows** + +In both, change the 6c row from "planned" to "shipped": + +``` +| 6c | Raycast + Alfred + WidgetKit + idle detection | ✅ shipped | +``` + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "docs: phase 6c surfaces — Raycast + Alfred + WidgetKit + idle live" +``` + +--- + +## Task E3: CI integration + +**Files:** +- Modify: `.github/workflows/ci.yml` — add Swift Widget test step +- Modify: `.github/workflows/release-artifacts.yml` — sign the .appex +- Optional: Raycast extension lint job, Alfred shellcheck job + +- [ ] **Step 1: ci.yml — add widget test** + +Right after the existing "Swift test (StintIntents framework)" step: + +```yaml + - name: Swift test (StintWidget) + working-directory: crates/stint-app/swift/StintWidget + run: xcodebuild -scheme StintWidget -destination 'platform=macOS' -derivedDataPath ./build/derived test +``` + +- [ ] **Step 2: release-artifacts.yml — sign the .appex** + +After the existing framework codesign, add (keeping the bash-injection-safe form): + +```yaml + - name: Sign StintWidget.appex + env: + IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + APPEX="${APP_PATH}/Contents/PlugIns/StintWidget.appex" + codesign --force --options runtime --sign "$IDENTITY" "$APPEX" + codesign --verify --strict --verbose=2 "$APPEX" +``` + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ +git commit -m "ci(widget): swift test step + .appex codesign in release pipeline" +``` + +--- + +## Task E4: Full verification + +- [ ] **Step 1: Format + lint + tests + coverage** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace -- --test-threads=1 +cd ui && pnpm typecheck && pnpm vitest run && cd .. +cd crates/stint-app/swift/StintIntents && xcodebuild test -scheme StintIntents -destination 'platform=macOS' -derivedDataPath ./build/derived | tail -5 && cd - +cd crates/stint-app/swift/StintWidget && xcodebuild test -scheme StintWidget -destination 'platform=macOS' -derivedDataPath ./build/derived | tail -5 && cd - +scripts/coverage.sh | tail -10 +``` + +Expected: green across the board, all coverage surfaces ≥ 80%. + +- [ ] **Step 2: Manual smoke** + +In your real environment: +- **Raycast**: import `raycast-stint/` via "Import Extension"; run `Start Timer` → verify entry appears in stint. +- **Alfred**: bundle `alfred-stint/` via zip; double-click to import; type `s test alfred` → verify entry. +- **Widget**: right-click desktop → Edit Widgets → add Stint Running Timer → verify it shows the current timer. +- **Idle**: set `idle.threshold_secs = 60` in Settings; start a timer; lock the screen for 90s; unlock → banner should appear. + +- [ ] **Step 3: Commit a manual-smoke marker** + +```bash +git commit --allow-empty -m "test(6c): manual smoke checklist exercised — all 4 surfaces verified" +``` + +--- + +## Task E5: Tag phase-6c-complete (LOCAL ONLY) + +- [ ] **Step 1: Sanity check no uncommitted changes** + +```bash +git status +``` + +Expected: clean. + +- [ ] **Step 2: Tag** + +```bash +git tag -a phase-6c-complete -m "Phase 6c complete — Raycast + Alfred + WidgetKit + idle detection" +git log --oneline | head -5 +``` + +- [ ] **Step 3: STOP** + +Surface to user: "Phase 6c is complete on local branch, tagged `phase-6c-complete`. Ready to push and open the PR?" + +DO NOT `git push` or open a PR. The user explicitly governs push/release. + +--- + +## Self-review + +After writing the complete plan, look at the spec with fresh eyes and check the plan against it. Quick checklist: + +**Spec coverage:** +- §2 Scope: all 4 surfaces ✓ (Tasks A–D), CLI extension ✓ (A2), `api.port` file ✓ (A1). +- §3 Architecture: `api.port` discovery ✓ (A1), idle worker ✓ (A3-A4), widget-presence-aware HTTP auto-enable ✓ (E1), build pipeline ✓ (D6-D7). +- §4 Raycast: 5 commands ✓ (B1-B6). +- §5 Alfred: 4 keywords ✓ (C1-scripts). +- §6 Widget: configurable per-instance ✓ (D5), 3 kinds × 2 sizes ✓ (D4-D5), HTTP/port discovery ✓ (D2-D3), timeline strategy ✓ (D3), auto-enable HTTP ✓ (E1), deep-link tap targets — partial; the URL routing relies on stint-app's existing deep-link handler from 6b. No new code needed; documented in §6.7 of spec. +- §7 Idle: state machine ✓ (A3), threshold + settings ✓ (A7), 3 Tauri commands ✓ (A5), banner UI ✓ (A6). +- §8-9 Data flow / error handling: woven into per-task content. +- §10 Testing: TDD per task; manual smoke in E4. +- §11 Trade-offs: documented inline. + +**Placeholder scan:** the only forward-reference is a `TODO(6c.1)` in IdleBanner.tsx (A6) for the pre-fill behavior on Discard+restart — that's an honest follow-up item, not a "fix me before merge". Acceptable. + +**Type consistency:** Swift `EntryDTO` snake_case matches Rust serde shapes (verified against verbs/types.rs). `WidgetKind` enum cases match the rawValue strings used in `WidgetProjectQuery`. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-05-27-stint-phase-6c-power-user-surfaces.md`.** Two execution options: + +**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, two-stage review between tasks, fast iteration. + +**2. Inline Execution** — execute tasks in this session via executing-plans, batch execution with checkpoints. + +**Which approach?** diff --git a/docs/superpowers/plans/2026-05-28-stint-phase-6d-xcode-extensions.md b/docs/superpowers/plans/2026-05-28-stint-phase-6d-xcode-extensions.md new file mode 100644 index 0000000..318efd0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-stint-phase-6d-xcode-extensions.md @@ -0,0 +1,2954 @@ +# Phase 6d — Xcode-Based Extensions Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace stint-app's SPM-based Swift build with an xcodegen-driven Xcode project hosting one shared framework target plus two extension targets, unblocking both the deferred 6b App Intents work and the broken 6c WidgetKit loading. + +**Architecture:** XcodeGen-generated `.xcodeproj` with three targets: `StintExtensionsCore` (shared framework), `StintIntentsExtension` (App Intents `.appex`), `StintWidget` (Widget `.appex`). `build.rs` runs `xcodegen generate` + `xcodebuild build` for each extension scheme, repackages the resulting `.appex` bundles into `crates/stint-app/PlugIns/` for Tauri to embed. Spotlight indexing moves from in-process (dlsym from stint-app into the framework) to cross-process (host writes pending-reindex marker to App Group container, posts Darwin notification, extension drains). + +**Tech Stack:** XcodeGen (`brew install xcodegen`), xcodebuild, Swift 5.9+, macOS 14+ (App Intents Extensions require it), Core Foundation Darwin notifications, App Groups, Rust `libc::dlsym` (retained for cross-binary calls but pointed at new symbols). + +**Branch:** Start from `main` AFTER the 6c PR lands. New branch `phase-6d`. Do NOT execute this plan from `feature/task-assignment` — the spec's Step D deletes paths that 6c depends on, and the diff against main will be unreadable if 6c hasn't merged first. + +**Pre-flight:** + +```bash +# Ensure 6c is on main first +git checkout main && git pull +git log --oneline | head -5 # expect 6c commits at top + +# Required toolchain +brew install xcodegen # ~5 MB; pure Swift +xcodebuild -version # expect Xcode 15+ +swift --version # expect Swift 5.9+ + +# Start the branch +git checkout -b phase-6d +``` + +**Phase exit criteria** (each independently shippable per spec §6): + +- **Phase A** ✅ when the WidgetKit widget appears in the macOS Edit Widgets gallery after notarized install. +- **Phase B** ✅ when `pluginkit -m -p com.apple.appintents-extension | grep stint` lists the intents extension AND Shortcuts.app discovers stint actions. +- **Phase C** ✅ when mutating an entry triggers Spotlight reindex within ~10 seconds (test: change description, wait, search for new text). +- **Phase D** ✅ when the legacy framework path is gone, full `scripts/coverage.sh` is green, and all 8 manual smoke checks from spec §7 pass. + +**NEVER push or merge to main without explicit user approval. NEVER trigger releases. NEVER use `--no-verify` or `--no-gpg-sign` unless the user explicitly asks for it.** + +--- + +# Phase A — XcodeGen + Widget Extension + +Goal: replace the SPM-based widget build with an xcodegen-driven Xcode build of one shared framework + one Widget Extension target. End state: the widget appears in the macOS Edit Widgets gallery. + +--- + +## Task A1: Document the xcodegen dependency + +**Files:** +- Modify: `scripts/dev-app.sh` +- Modify: `README.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Add xcodegen check to scripts/dev-app.sh** + +Find the line near the top that does dependency checks (look for `command -v` or the comment block about first-time setup). Add this guard just before the first `cargo build` invocation: + +```bash +# Phase 6d: xcodegen drives the Swift extension builds. +if ! command -v xcodegen >/dev/null 2>&1; then + echo "error: xcodegen not installed. Install: brew install xcodegen" >&2 + exit 1 +fi +``` + +- [ ] **Step 2: Update README.md first-time setup** + +Find the "First-time setup on a fresh machine" section in README.md. Update the brew install line to include xcodegen: + +```bash +brew install pnpm rust xcodegen +``` + +- [ ] **Step 3: Update CLAUDE.md Gotchas section** + +Append a new bullet to the "Gotchas / dev-environment notes" section of `CLAUDE.md`: + +```markdown +- **xcodegen drives Swift extension builds.** As of Phase 6d, the `.xcodeproj` + that produces `StintIntentsExtension.appex` and `StintWidget.appex` is + generated from `crates/stint-app/swift/xcodegen/project.yml` by xcodegen at + build time. The `.xcodeproj` itself is gitignored — never commit it. + Install once: `brew install xcodegen`. `scripts/dev-app.sh` checks for it + and fails fast with a clear error if missing. +``` + +- [ ] **Step 4: Commit** + +```bash +git add scripts/dev-app.sh README.md CLAUDE.md +git commit -m "docs(6d): xcodegen dependency for Swift extension builds" +``` + +--- + +## Task A2: Scaffold xcodegen directory + .gitignore + +**Files:** +- Create: `crates/stint-app/swift/xcodegen/.gitignore` +- Create: `crates/stint-app/swift/xcodegen/README.md` + +- [ ] **Step 1: Create the gitignore** + +```bash +mkdir -p crates/stint-app/swift/xcodegen +cat > crates/stint-app/swift/xcodegen/.gitignore <<'EOF' +StintExtensions.xcodeproj/ +build/ +.build/ +DerivedData/ +EOF +``` + +- [ ] **Step 2: Create the README** + +```bash +cat > crates/stint-app/swift/xcodegen/README.md <<'EOF' +# StintExtensions Xcode project source + +The `.xcodeproj` here is generated from `project.yml` by [xcodegen](https://github.com/yonaskolb/XcodeGen). Never edit `StintExtensions.xcodeproj/` directly — it's gitignored and regenerated on every build. + +## Manual regenerate (rare) + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate +open StintExtensions.xcodeproj # if you want to inspect in Xcode +``` + +`build.rs` runs `xcodegen generate` automatically before each `xcodebuild` invocation. + +## Targets + +- `StintExtensionsCore` — framework: shared Swift code (DTOs, PortDiscovery, IPC helpers, intent type declarations, Spotlight indexer). +- `StintIntentsExtension` — App Intents Extension `.appex`: registers intents with Siri/Shortcuts/Focus, drains Spotlight reindex queue. +- `StintWidget` — Widget Extension `.appex`: WidgetKit widget bundle. +- `StintExtensionsCoreTests` — test target against `StintExtensionsCore`. +EOF +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/swift/xcodegen/ +git commit -m "chore(6d): scaffold xcodegen/ directory" +``` + +--- + +## Task A3: Create StintExtensionsCore source skeleton + copy widget-side DTOs + +**Files:** +- Create: `crates/stint-app/swift/StintExtensionsCore/Sources/Models/PortDiscovery.swift` (copy from legacy) +- Create: `crates/stint-app/swift/StintExtensionsCore/Sources/Models/EntryDTO.swift` (copy) +- Create: `crates/stint-app/swift/StintExtensionsCore/Sources/Models/ProjectDTO.swift` (copy) + +The legacy `crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/` directory has the originals. Copy them — the legacy SPM package stays in tree until Step D, so we duplicate rather than move. + +- [ ] **Step 1: Create the directory tree** + +```bash +mkdir -p crates/stint-app/swift/StintExtensionsCore/Sources/Models +mkdir -p crates/stint-app/swift/StintExtensionsCore/Tests +``` + +- [ ] **Step 2: Copy the three model files** + +```bash +cp crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/PortDiscovery.swift \ + crates/stint-app/swift/StintExtensionsCore/Sources/Models/PortDiscovery.swift +cp crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/EntryDTO.swift \ + crates/stint-app/swift/StintExtensionsCore/Sources/Models/EntryDTO.swift +cp crates/stint-app/swift/StintWidget/Sources/StintWidget/Models/ProjectDTO.swift \ + crates/stint-app/swift/StintExtensionsCore/Sources/Models/ProjectDTO.swift +``` + +- [ ] **Step 3: Verify files exist and are non-empty** + +```bash +wc -l crates/stint-app/swift/StintExtensionsCore/Sources/Models/*.swift +``` + +Expected: three files, each between 10 and 50 lines. + +- [ ] **Step 4: Commit** + +```bash +git add crates/stint-app/swift/StintExtensionsCore/ +git commit -m "chore(6d): copy widget DTO + PortDiscovery into StintExtensionsCore" +``` + +--- + +## Task A4: Migrate widget test files into StintExtensionsCoreTests + +**Files:** +- Create: `crates/stint-app/swift/StintExtensionsCore/Tests/PortDiscoveryTests.swift` (copy from legacy) +- Create: `crates/stint-app/swift/StintExtensionsCore/Tests/DTOCodingTests.swift` (copy from legacy) + +- [ ] **Step 1: Copy both test files** + +```bash +cp crates/stint-app/swift/StintWidget/Tests/StintWidgetTests/PortDiscoveryTests.swift \ + crates/stint-app/swift/StintExtensionsCore/Tests/PortDiscoveryTests.swift +cp crates/stint-app/swift/StintWidget/Tests/StintWidgetTests/DTOCodingTests.swift \ + crates/stint-app/swift/StintExtensionsCore/Tests/DTOCodingTests.swift +``` + +- [ ] **Step 2: Update the `@testable import` lines** + +Both files have `@testable import StintWidget` on a line near the top. Change to `@testable import StintExtensionsCore` in both files: + +```bash +sed -i '' 's/@testable import StintWidget$/@testable import StintExtensionsCore/' \ + crates/stint-app/swift/StintExtensionsCore/Tests/PortDiscoveryTests.swift \ + crates/stint-app/swift/StintExtensionsCore/Tests/DTOCodingTests.swift +``` + +- [ ] **Step 3: Verify the import line** + +```bash +grep "import StintExtensionsCore" crates/stint-app/swift/StintExtensionsCore/Tests/*.swift +``` + +Expected: one match per file (both files). + +- [ ] **Step 4: Commit** + +```bash +git add crates/stint-app/swift/StintExtensionsCore/Tests/ +git commit -m "test(6d): migrate widget DTO + PortDiscovery tests to StintExtensionsCoreTests" +``` + +--- + +## Task A5: Create project.yml with StintExtensionsCore framework target only + +**Files:** +- Create: `crates/stint-app/swift/xcodegen/project.yml` + +- [ ] **Step 1: Write project.yml with just the framework + test targets** + +This first version has no extension targets yet — we verify the framework builds + tests pass in isolation before adding extensions. + +```yaml +name: StintExtensions + +options: + deploymentTarget: + macOS: "14.0" + bundleIdPrefix: tech.reyem.stint + createIntermediateGroups: true + developmentLanguage: en + +settings: + base: + SWIFT_VERSION: "5.9" + MACOSX_DEPLOYMENT_TARGET: "14.0" + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "-" + +targets: + StintExtensionsCore: + type: framework + platform: macOS + sources: + - path: ../StintExtensionsCore/Sources + info: + path: ../StintExtensionsCore/Info.plist + properties: + CFBundleIdentifier: tech.reyem.stint.extensions.core + settings: + base: + PRODUCT_NAME: StintExtensionsCore + PRODUCT_BUNDLE_IDENTIFIER: tech.reyem.stint.extensions.core + DEFINES_MODULE: YES + SKIP_INSTALL: NO + + StintExtensionsCoreTests: + type: bundle.unit-test + platform: macOS + sources: + - path: ../StintExtensionsCore/Tests + dependencies: + - target: StintExtensionsCore + settings: + base: + BUNDLE_LOADER: "$(TEST_HOST)" + PRODUCT_BUNDLE_IDENTIFIER: tech.reyem.stint.extensions.core.tests +``` + +- [ ] **Step 2: Generate the project and verify** + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate 2>&1 | tail -5 +ls -d StintExtensions.xcodeproj +cd - +``` + +Expected: `Loaded project ... Generated project successfully.` and the `.xcodeproj` directory exists. + +- [ ] **Step 3: Run the test suite to verify the framework + tests compile + pass** + +```bash +cd crates/stint-app/swift/xcodegen +xcodebuild test -scheme StintExtensionsCoreTests -destination 'platform=macOS' -derivedDataPath ./build/derived 2>&1 | tail -8 +cd - +``` + +Expected: `** TEST SUCCEEDED **` and `5 tests` reported. + +- [ ] **Step 4: Commit** + +```bash +git add crates/stint-app/swift/xcodegen/project.yml +git commit -m "feat(6d): xcodegen project.yml — StintExtensionsCore framework + tests" +``` + +--- + +## Task A6: Copy widget source into Extensions/StintWidget/ + +**Files:** +- Create: `crates/stint-app/swift/Extensions/StintWidget/Sources/WidgetMain.swift` (copy of StintWidgetBundle.swift) +- Create: `crates/stint-app/swift/Extensions/StintWidget/Sources/RunningTimerWidget.swift` (copy) +- Create: `crates/stint-app/swift/Extensions/StintWidget/Sources/Provider.swift` (copy) +- Create: `crates/stint-app/swift/Extensions/StintWidget/Sources/WidgetConfigIntent.swift` (copy) +- Create: `crates/stint-app/swift/Extensions/StintWidget/Sources/Views/RunningTimerView.swift` (copy) +- Create: `crates/stint-app/swift/Extensions/StintWidget/Sources/Views/TodayTotalView.swift` (copy) +- Create: `crates/stint-app/swift/Extensions/StintWidget/Sources/Views/WeekProjectView.swift` (copy) + +- [ ] **Step 1: Create the directory tree** + +```bash +mkdir -p crates/stint-app/swift/Extensions/StintWidget/Sources/Views +``` + +- [ ] **Step 2: Copy the files** + +```bash +cp crates/stint-app/swift/StintWidget/Sources/StintWidget/StintWidgetBundle.swift \ + crates/stint-app/swift/Extensions/StintWidget/Sources/WidgetMain.swift +cp crates/stint-app/swift/StintWidget/Sources/StintWidget/RunningTimerWidget.swift \ + crates/stint-app/swift/Extensions/StintWidget/Sources/ +cp crates/stint-app/swift/StintWidget/Sources/StintWidget/Provider.swift \ + crates/stint-app/swift/Extensions/StintWidget/Sources/ +cp crates/stint-app/swift/StintWidget/Sources/StintWidget/WidgetConfigIntent.swift \ + crates/stint-app/swift/Extensions/StintWidget/Sources/ +cp crates/stint-app/swift/StintWidget/Sources/StintWidget/Views/*.swift \ + crates/stint-app/swift/Extensions/StintWidget/Sources/Views/ +``` + +- [ ] **Step 3: Add `import StintExtensionsCore` to files that reference moved types** + +`Provider.swift` references `PortDiscovery`, `EntryDTO`. `WidgetConfigIntent.swift` references `PortDiscovery`, `ProjectDTO`. Add the import after the existing `import Foundation` / `import WidgetKit` lines. + +```bash +for f in \ + crates/stint-app/swift/Extensions/StintWidget/Sources/Provider.swift \ + crates/stint-app/swift/Extensions/StintWidget/Sources/WidgetConfigIntent.swift; do + # Insert "import StintExtensionsCore" after the last existing "import " line + awk '/^import / { last=NR } { lines[NR]=$0 } END { + for (i=1; i<=NR; i++) { print lines[i]; if (i==last) print "import StintExtensionsCore" } + }' "$f" > "$f.tmp" && mv "$f.tmp" "$f" +done +``` + +- [ ] **Step 4: Verify the imports** + +```bash +grep -l "import StintExtensionsCore" crates/stint-app/swift/Extensions/StintWidget/Sources/{Provider,WidgetConfigIntent}.swift +``` + +Expected: both files listed. + +- [ ] **Step 5: Commit** + +```bash +git add crates/stint-app/swift/Extensions/StintWidget/Sources/ +git commit -m "chore(6d): copy widget source into Extensions/StintWidget/" +``` + +--- + +## Task A7: Create Info.plist + entitlements for the widget extension target + +**Files:** +- Create: `crates/stint-app/swift/Extensions/StintWidget/Info.plist` +- Create: `crates/stint-app/swift/Extensions/StintWidget/StintWidget.entitlements` + +- [ ] **Step 1: Write the Info.plist** + +This is the same shape that worked end-of-6c (after the fix in commit `52bb6a4`): all platform-identification keys present. + +```bash +cat > crates/stint-app/swift/Extensions/StintWidget/Info.plist <<'EOF' + + + + + CFBundleIdentifier + tech.reyem.stint.widget + CFBundleExecutable + StintWidget + CFBundleName + StintWidget + CFBundleDisplayName + Stint Widget + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + XPC! + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDevelopmentRegion + en + CFBundleSupportedPlatforms + + MacOSX + + DTPlatformName + macosx + LSMinimumSystemVersion + 14.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + +EOF +``` + +- [ ] **Step 2: Write the entitlements** + +```bash +cat > crates/stint-app/swift/Extensions/StintWidget/StintWidget.entitlements <<'EOF' + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + +EOF +``` + +Note: the App Group entitlement is intentionally absent at this step — it gets added in Phase C when we wire IPC. Phase A's widget just fetches via HTTP loopback. + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/swift/Extensions/StintWidget/Info.plist \ + crates/stint-app/swift/Extensions/StintWidget/StintWidget.entitlements +git commit -m "feat(6d): Info.plist + entitlements for new StintWidget extension target" +``` + +--- + +## Task A8: Add StintWidget extension target to project.yml + +**Files:** +- Modify: `crates/stint-app/swift/xcodegen/project.yml` + +- [ ] **Step 1: Append the StintWidget target** + +Open `crates/stint-app/swift/xcodegen/project.yml`. Below the existing `StintExtensionsCoreTests:` block (which is the last target), add: + +```yaml + StintWidget: + type: app-extension + platform: macOS + sources: + - path: ../Extensions/StintWidget/Sources + info: + path: ../Extensions/StintWidget/Info.plist + entitlements: + path: ../Extensions/StintWidget/StintWidget.entitlements + dependencies: + - target: StintExtensionsCore + embed: false + settings: + base: + PRODUCT_NAME: StintWidget + PRODUCT_BUNDLE_IDENTIFIER: tech.reyem.stint.widget + WRAPPER_EXTENSION: appex + SKIP_INSTALL: YES + LD_RUNPATH_SEARCH_PATHS: + - "@executable_path/../../Frameworks" + SWIFT_OPTIMIZATION_LEVEL: "-Onone" + info: + path: ../Extensions/StintWidget/Info.plist + properties: + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension +``` + +- [ ] **Step 2: Regenerate + build the new target** + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate 2>&1 | tail -3 +xcodebuild build -scheme StintWidget -configuration Release -destination 'platform=macOS' -derivedDataPath ./build/derived 2>&1 | tail -5 +cd - +``` + +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 3: Verify the produced .appex shape** + +```bash +APPEX="crates/stint-app/swift/xcodegen/build/derived/Build/Products/Release/StintWidget.appex" +ls "$APPEX/Contents/" && file "$APPEX/Contents/MacOS/StintWidget" +``` + +Expected: `Info.plist`, `MacOS/`, `Frameworks/` directories. The Mach-O is `Mach-O 64-bit executable arm64` (or universal). + +- [ ] **Step 4: Commit** + +```bash +git add crates/stint-app/swift/xcodegen/project.yml +git commit -m "feat(6d): xcodegen StintWidget extension target — produces real .appex" +``` + +--- + +## Task A9: Swap build.rs to drive xcodegen + xcodebuild for the widget + +**Files:** +- Modify: `crates/stint-app/build.rs` + +The current `build_stint_widget()` function calls `xcodebuild` against the legacy SPM Package.swift. Replace its body with one that runs `xcodegen generate` + `xcodebuild build` against the new project.yml. The legacy SPM widget package stays in tree (Step D deletes it) but is no longer consumed by build.rs. + +- [ ] **Step 1: Replace build_stint_widget() entirely** + +Open `crates/stint-app/build.rs`. Replace the entire `fn build_stint_widget()` function (and its doc comment) with this: + +```rust +/// Build the StintWidget app extension via xcodegen + xcodebuild and +/// place the resulting `.appex` bundle at +/// `crates/stint-app/PlugIns/StintWidget.appex/` where Tauri's bundle +/// step picks it up. Set `STINT_SKIP_SWIFT_BUILD=1` to skip. +fn build_stint_widget() -> Result<(), String> { + if env::var_os("STINT_SKIP_SWIFT_BUILD").is_some_and(|v| !v.is_empty()) { + return Err("STINT_SKIP_SWIFT_BUILD is set".into()); + } + if env::var_os("CARGO_CFG_TARGET_OS").is_some_and(|v| v != "macos") { + return Err("non-macOS target".into()); + } + + let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?; + let xcodegen_dir = Path::new(&manifest_dir).join("swift/xcodegen"); + let project_yml = xcodegen_dir.join("project.yml"); + if !project_yml.exists() { + return Err(format!("missing {}", project_yml.display())); + } + + println!("cargo:rerun-if-changed={}", project_yml.display()); + let extensions_dir = Path::new(&manifest_dir).join("swift/Extensions/StintWidget"); + let core_dir = Path::new(&manifest_dir).join("swift/StintExtensionsCore"); + for src in [extensions_dir.as_path(), core_dir.as_path()] { + if let Ok(entries) = fs::read_dir(src) { + for entry in entries.flatten() { + print_rerun_if_changed_recursive(&entry.path()); + } + } + } + + // Generate the .xcodeproj from project.yml (idempotent). + let xcgen = Command::new("xcodegen") + .current_dir(&xcodegen_dir) + .arg("generate") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .status() + .map_err(|e| format!("xcodegen spawn (is `brew install xcodegen` done?): {e}"))?; + if !xcgen.success() { + return Err(format!("xcodegen exit {xcgen}")); + } + + let derived_data = xcodegen_dir.join("build/derived"); + let status = Command::new("xcodebuild") + .current_dir(&xcodegen_dir) + .args([ + "-scheme", + "StintWidget", + "-configuration", + "Release", + "-destination", + "platform=macOS", + "-derivedDataPath", + derived_data.to_str().ok_or("derived path not utf8")?, + "build", + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .status() + .map_err(|e| format!("xcodebuild spawn: {e}"))?; + if !status.success() { + return Err(format!("xcodebuild exit {status}")); + } + + let built = derived_data.join("Build/Products/Release/StintWidget.appex"); + if !built.exists() { + return Err(format!("missing {}", built.display())); + } + + let dest = Path::new(&manifest_dir).join("PlugIns/StintWidget.appex"); + let _ = fs::remove_dir_all(&dest); + fs::create_dir_all(dest.parent().unwrap()).map_err(|e| format!("create PlugIns/: {e}"))?; + copy_dir(&built, &dest).map_err(|e| format!("copy appex: {e}"))?; + + codesign_adhoc(&dest).map_err(|e| format!("codesign appex: {e}"))?; + + println!( + "cargo:warning=StintWidget.appex rebuilt at {}", + dest.display() + ); + Ok(()) +} +``` + +- [ ] **Step 2: Cargo build to verify** + +```bash +cargo build -p stint-app 2>&1 | tail -6 +``` + +Expected: `Finished` line plus a `cargo:warning=StintWidget.appex rebuilt at …` line. + +- [ ] **Step 3: Verify the produced bundle** + +```bash +file crates/stint-app/PlugIns/StintWidget.appex/Contents/MacOS/StintWidget +ls crates/stint-app/PlugIns/StintWidget.appex/Contents/ +``` + +Expected: `Mach-O 64-bit executable arm64` and directories `Frameworks/ Info.plist MacOS/ _CodeSignature/`. + +- [ ] **Step 4: Commit** + +```bash +git add crates/stint-app/build.rs +git commit -m "build(6d): build.rs drives xcodegen + xcodebuild for StintWidget appex" +``` + +--- + +## Task A10: Update build-app-with-widget.sh for new bundle layout + +**Files:** +- Modify: `scripts/build-app-with-widget.sh` + +The script's logic is correct; only thing changing is the `.appex` now ships a `Frameworks/StintExtensionsCore.framework` inside it (from the framework dep). The existing relocation + sign + verify still works as-is. Verify with a dry run. + +- [ ] **Step 1: Run the wrapper script with ad-hoc sign** + +```bash +scripts/build-app-with-widget.sh 2>&1 | tail -10 +``` + +Expected: `Done. Bundle at target/release/bundle/macos/Stint.app` and a successful `codesign --verify`. + +- [ ] **Step 2: Verify the appex contains the embedded framework** + +```bash +ls target/release/bundle/macos/Stint.app/Contents/PlugIns/StintWidget.appex/Contents/Frameworks/ +``` + +Expected: `StintExtensionsCore.framework`. + +- [ ] **Step 3: Commit** (no source change, marker only) + +```bash +git commit --allow-empty -m "test(6d): verified xcodegen-built widget appex bundles cleanly via wrapper" +``` + +--- + +## Task A11: Notarize, install, and verify widget gallery + +**Files:** none. + +This is a manual verification gate. The output of this task is a paste of `pluginkit -m -p com.apple.widgetkit-extension | grep stint` into the commit message. + +- [ ] **Step 1: Sign with Developer ID + notarize** + +```bash +scripts/build-app-with-widget.sh "Developer ID Application: Reyem Technologies Inc. (WAK5K2758P)" + +APP="target/release/bundle/macos/Stint.app" +ZIP="${APP}.zip" +rm -f "$ZIP" +ditto -c -k --keepParent "$APP" "$ZIP" + +xcrun notarytool submit "$ZIP" --keychain-profile "stint-notary" --wait +``` + +Expected: `status: Accepted`. + +- [ ] **Step 2: Staple + install** + +```bash +xcrun stapler staple "$APP" +killall stint-app 2>/dev/null; sleep 1 +rm -rf /Applications/Stint.app +cp -R "$APP" /Applications/ +xattr -cr /Applications/Stint.app +/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f /Applications/Stint.app +open /Applications/Stint.app +sleep 6 +``` + +- [ ] **Step 3: Verify pluginkit registers the widget** + +```bash +pluginkit -m -p com.apple.widgetkit-extension | grep -i stint +``` + +Expected: `tech.reyem.stint.widget(1.0)` appears. + +- [ ] **Step 4: Manually verify the gallery** + +1. Right-click the desktop → Edit Widgets. +2. Search "Stint". +3. Expect the Stint widget tile with three configurations (Running Timer / Today Total / This-Week Project) × two sizes (small, medium). +4. Drag one onto the desktop. +5. The widget renders the "Stint not running" placeholder (HTTP API isn't auto-enabled until Phase C wires the new IPC), OR — if you previously enabled `api.enabled = true` in Settings — it shows the current timer. + +- [ ] **Step 5: Commit verification marker** + +```bash +git commit --allow-empty -m "test(6d): Phase A — widget appears in macOS Edit Widgets gallery + +pluginkit confirms tech.reyem.stint.widget(1.0) registered after +notarized install. Gallery shows configurable widget with both sizes. +End-state of Phase A reached: SPM widget build replaced with xcodegen- +driven build; widget loads + renders without the EXRunningExtension +crash that 6c hit." +``` + +--- + +## Task A12: Add xcodegen + StintExtensionsCore test step to CI + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Add xcodegen install step before the Swift test steps** + +Open `.github/workflows/ci.yml`. Find the existing line `- name: Swift test (StintIntents framework)` (around line 57). Add a new step ABOVE it: + +```yaml + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + working-directory: crates/stint-app/swift/xcodegen + run: xcodegen generate + + - name: Swift test (StintExtensionsCore) + working-directory: crates/stint-app/swift/xcodegen + run: xcodebuild test -scheme StintExtensionsCoreTests -destination 'platform=macOS' -derivedDataPath ./build/derived +``` + +Keep the existing `Swift test (StintIntents framework)` and `Swift test (StintWidget)` steps in place — they cover the legacy SPM packages, which Phase D deletes. Phase A is additive. + +- [ ] **Step 2: Verify yaml is valid** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" && echo "ci.yml: valid YAML" +``` + +Expected: `ci.yml: valid YAML`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci(6d): xcodegen install + StintExtensionsCore test step" +``` + +--- + +# Phase B — App Intents Extension target + +Goal: introduce a real `.appex` for App Intents alongside the working framework, so Siri/Shortcuts/Focus start discovering the intent types. The legacy framework keeps running and serving Spotlight via dlsym throughout this phase. + +--- + +## Task B1: Copy intent type sources into StintExtensionsCore + +**Files:** +- Create: `crates/stint-app/swift/StintExtensionsCore/Sources/Entities/EntryEntity.swift` (copy) +- Create: `.../Entities/EntryQuery.swift` (copy) +- Create: `.../Entities/ProjectEntity.swift` (copy) +- Create: `.../Entities/ProjectQuery.swift` (copy) +- Create: `.../Entities/TaskEntity.swift` (copy) +- Create: `.../Entities/TaskQuery.swift` (copy) +- Create: `.../Errors/BridgeError.swift` (copy) +- Create: `.../Intents/StartTimerIntent.swift` (copy) +- Create: `.../Intents/StopTimerIntent.swift` (copy) +- Create: `.../Intents/GetCurrentIntent.swift` (copy) +- Create: `.../Intents/ListEntriesIntent.swift` (copy) +- Create: `.../Intents/ListProjectsIntent.swift` (copy) +- Create: `.../Intents/ListTasksIntent.swift` (copy) +- Create: `.../Intents/SwitchProjectIntent.swift` (copy) +- Create: `.../Intents/UpdateEntryIntent.swift` (copy) +- Create: `.../Intents/DeleteEntryIntent.swift` (copy) +- Create: `.../Intents/LogPastIntent.swift` (copy) +- Create: `.../Shortcuts/StintAppShortcutsProvider.swift` (copy) +- Create: `.../Shortcuts/PhraseStrings.xcstrings` (copy) +- Create: `.../Bridge/RustFFI.swift` (copy of Bridge.swift, renamed) + +These copies leave the legacy framework's originals intact. Phase D deletes them. + +- [ ] **Step 1: Create directory tree** + +```bash +mkdir -p crates/stint-app/swift/StintExtensionsCore/Sources/{Entities,Errors,Intents,Shortcuts,Bridge} +``` + +- [ ] **Step 2: Bulk copy** + +```bash +SRC=crates/stint-app/swift/StintIntents/Sources/StintIntents +DST=crates/stint-app/swift/StintExtensionsCore/Sources + +cp $SRC/Entities/*.swift $DST/Entities/ +cp $SRC/Errors/*.swift $DST/Errors/ +cp $SRC/Intents/*.swift $DST/Intents/ +cp $SRC/Shortcuts/StintAppShortcutsProvider.swift $DST/Shortcuts/ +cp $SRC/Shortcuts/PhraseStrings.xcstrings $DST/Shortcuts/ +cp $SRC/Bridge.swift $DST/Bridge/RustFFI.swift +``` + +- [ ] **Step 3: Sanity-count** + +```bash +find crates/stint-app/swift/StintExtensionsCore/Sources -name "*.swift" | wc -l +``` + +Expected: `21` (3 Models + 6 Entities + 1 Error + 10 Intents + 1 Shortcuts + 1 Bridge — adjust if your inventory differs). + +- [ ] **Step 4: Regenerate the project and verify the framework still compiles** + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate 2>&1 | tail -3 +xcodebuild build -scheme StintExtensionsCore -configuration Release -destination 'platform=macOS' -derivedDataPath ./build/derived 2>&1 | tail -5 +cd - +``` + +Expected: `** BUILD SUCCEEDED **`. + +If there are compile errors about missing dependencies (e.g. `swift_indexer_notify` symbol or `init_swift_init` symbol), DON'T fix them by moving more code — instead, add `#if canImport(WidgetKit)` guards or `@available(macOS 14, *)` annotations to the offending types ONLY if the error is platform-related. For missing C symbols (Rust FFI), the bridge file expects those symbols to be available at link time; this is fine because the framework is built with `-undefined dynamic_lookup`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/stint-app/swift/StintExtensionsCore/Sources/ +git commit -m "chore(6d): copy intent types + entities into StintExtensionsCore" +``` + +--- + +## Task B2: Create the StintIntentsExtension Info.plist + entitlements + +**Files:** +- Create: `crates/stint-app/swift/Extensions/StintIntentsExtension/Info.plist` +- Create: `crates/stint-app/swift/Extensions/StintIntentsExtension/StintIntentsExtension.entitlements` + +- [ ] **Step 1: Info.plist** + +```bash +mkdir -p crates/stint-app/swift/Extensions/StintIntentsExtension +cat > crates/stint-app/swift/Extensions/StintIntentsExtension/Info.plist <<'EOF' + + + + + CFBundleIdentifier + tech.reyem.stint.intents + CFBundleExecutable + StintIntentsExtension + CFBundleName + StintIntentsExtension + CFBundleDisplayName + Stint Intents + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + XPC! + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDevelopmentRegion + en + CFBundleSupportedPlatforms + + MacOSX + + DTPlatformName + macosx + LSMinimumSystemVersion + 14.0 + NSAppIntentsPackage + + EXAppExtensionAttributes + + EXExtensionPointIdentifier + com.apple.appintents-extension + + + +EOF +``` + +- [ ] **Step 2: Entitlements** + +```bash +cat > crates/stint-app/swift/Extensions/StintIntentsExtension/StintIntentsExtension.entitlements <<'EOF' + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + +EOF +``` + +App Group entitlement gets added in Phase C when Spotlight IPC lands. + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/swift/Extensions/StintIntentsExtension/ +git commit -m "feat(6d): Info.plist + entitlements for StintIntentsExtension" +``` + +--- + +## Task B3: Write IntentsExtensionMain.swift (@main AppIntentsExtension) + +**Files:** +- Create: `crates/stint-app/swift/Extensions/StintIntentsExtension/Sources/IntentsExtensionMain.swift` + +- [ ] **Step 1: Create the source file** + +```bash +mkdir -p crates/stint-app/swift/Extensions/StintIntentsExtension/Sources +cat > crates/stint-app/swift/Extensions/StintIntentsExtension/Sources/IntentsExtensionMain.swift <<'EOF' +import AppIntents +import StintExtensionsCore + +@main +struct StintAppIntentsExtension: AppIntentsExtension { + // The extension's app intents come from the StintExtensionsCore framework + // via Apple's automatic discovery of any `AppIntent`-conforming type in + // any linked module. No manual registration is required. + // + // Apple's intent indexer (siriactionsd) scans this binary's + // Metadata.appintents stencil at install time and registers the discovered + // intents with Siri, Shortcuts.app, and Focus filter UI. +} +EOF +``` + +- [ ] **Step 2: Commit** + +```bash +git add crates/stint-app/swift/Extensions/StintIntentsExtension/Sources/ +git commit -m "feat(6d): @main AppIntentsExtension entry point" +``` + +--- + +## Task B4: Add StintIntentsExtension target to project.yml + +**Files:** +- Modify: `crates/stint-app/swift/xcodegen/project.yml` + +- [ ] **Step 1: Append the new target** + +Append to the bottom of `project.yml` (after the StintWidget target): + +```yaml + StintIntentsExtension: + type: app-extension + platform: macOS + sources: + - path: ../Extensions/StintIntentsExtension/Sources + info: + path: ../Extensions/StintIntentsExtension/Info.plist + entitlements: + path: ../Extensions/StintIntentsExtension/StintIntentsExtension.entitlements + dependencies: + - target: StintExtensionsCore + embed: false + settings: + base: + PRODUCT_NAME: StintIntentsExtension + PRODUCT_BUNDLE_IDENTIFIER: tech.reyem.stint.intents + WRAPPER_EXTENSION: appex + SKIP_INSTALL: YES + LD_RUNPATH_SEARCH_PATHS: + - "@executable_path/../../Frameworks" + SWIFT_OPTIMIZATION_LEVEL: "-Onone" + OTHER_LDFLAGS: + - "-Wl,-undefined,dynamic_lookup" +``` + +The `-undefined,dynamic_lookup` flag is required because RustFFI.swift declares external symbols (`stint_verb_*`, `stint_settings_*`, etc.) that get resolved at runtime from the host stint-app binary's flat namespace — same trick the legacy framework uses. + +- [ ] **Step 2: Regenerate + build** + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate 2>&1 | tail -3 +xcodebuild build -scheme StintIntentsExtension -configuration Release -destination 'platform=macOS' -derivedDataPath ./build/derived 2>&1 | tail -5 +cd - +``` + +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 3: Verify the .appex shape** + +```bash +APPEX="crates/stint-app/swift/xcodegen/build/derived/Build/Products/Release/StintIntentsExtension.appex" +ls "$APPEX/Contents/" && file "$APPEX/Contents/MacOS/StintIntentsExtension" +ls "$APPEX/Contents/Resources/Metadata.appintents/" 2>/dev/null && echo "✓ stencil present" +``` + +Expected: `Info.plist`, `MacOS/`, `Frameworks/`, `Resources/Metadata.appintents/` directories. The Metadata.appintents stencil is critical — that's what Apple's intent indexer reads. + +- [ ] **Step 4: Commit** + +```bash +git add crates/stint-app/swift/xcodegen/project.yml +git commit -m "feat(6d): StintIntentsExtension target — produces real App Intents .appex" +``` + +--- + +## Task B5: Add build_stint_intents_extension() to build.rs + +**Files:** +- Modify: `crates/stint-app/build.rs` + +- [ ] **Step 1: Add the new build function** + +Open `crates/stint-app/build.rs`. After the `build_stint_widget()` function (Phase A replaced its body), append a new function: + +```rust +/// Build the StintIntentsExtension app extension via xcodegen + xcodebuild +/// and place the resulting `.appex` bundle at +/// `crates/stint-app/PlugIns/StintIntentsExtension.appex/`. +fn build_stint_intents_extension() -> Result<(), String> { + if env::var_os("STINT_SKIP_SWIFT_BUILD").is_some_and(|v| !v.is_empty()) { + return Err("STINT_SKIP_SWIFT_BUILD is set".into()); + } + if env::var_os("CARGO_CFG_TARGET_OS").is_some_and(|v| v != "macos") { + return Err("non-macOS target".into()); + } + + let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?; + let xcodegen_dir = Path::new(&manifest_dir).join("swift/xcodegen"); + let project_yml = xcodegen_dir.join("project.yml"); + if !project_yml.exists() { + return Err(format!("missing {}", project_yml.display())); + } + + let ext_dir = Path::new(&manifest_dir).join("swift/Extensions/StintIntentsExtension"); + if let Ok(entries) = fs::read_dir(&ext_dir) { + for entry in entries.flatten() { + print_rerun_if_changed_recursive(&entry.path()); + } + } + + // xcodegen generate is idempotent; build_stint_widget() already runs it + // earlier in main(), but call again to be safe in case build order changes. + let xcgen = Command::new("xcodegen") + .current_dir(&xcodegen_dir) + .arg("generate") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .status() + .map_err(|e| format!("xcodegen spawn: {e}"))?; + if !xcgen.success() { + return Err(format!("xcodegen exit {xcgen}")); + } + + let derived_data = xcodegen_dir.join("build/derived"); + let status = Command::new("xcodebuild") + .current_dir(&xcodegen_dir) + .args([ + "-scheme", + "StintIntentsExtension", + "-configuration", + "Release", + "-destination", + "platform=macOS", + "-derivedDataPath", + derived_data.to_str().ok_or("derived path not utf8")?, + "build", + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .status() + .map_err(|e| format!("xcodebuild spawn: {e}"))?; + if !status.success() { + return Err(format!("xcodebuild exit {status}")); + } + + let built = derived_data.join("Build/Products/Release/StintIntentsExtension.appex"); + if !built.exists() { + return Err(format!("missing {}", built.display())); + } + + let dest = Path::new(&manifest_dir).join("PlugIns/StintIntentsExtension.appex"); + let _ = fs::remove_dir_all(&dest); + fs::create_dir_all(dest.parent().unwrap()).map_err(|e| format!("create PlugIns/: {e}"))?; + copy_dir(&built, &dest).map_err(|e| format!("copy appex: {e}"))?; + + codesign_adhoc(&dest).map_err(|e| format!("codesign appex: {e}"))?; + + println!( + "cargo:warning=StintIntentsExtension.appex rebuilt at {}", + dest.display() + ); + Ok(()) +} +``` + +- [ ] **Step 2: Wire into main()** + +Edit `main()` in build.rs. After the existing `build_stint_widget()` call, add: + +```rust + if let Err(e) = build_stint_intents_extension() { + println!("cargo:warning=StintIntentsExtension appex build skipped: {e}"); + } +``` + +Final `main()` should look like: + +```rust +fn main() { + if let Err(e) = build_stint_intents_framework() { + println!("cargo:warning=StintIntents framework build skipped: {e}"); + } + if let Err(e) = build_stint_widget() { + println!("cargo:warning=StintWidget appex build skipped: {e}"); + } + if let Err(e) = build_stint_intents_extension() { + println!("cargo:warning=StintIntentsExtension appex build skipped: {e}"); + } + tauri_build::build() +} +``` + +Note we keep `build_stint_intents_framework()` running — the legacy framework still provides Spotlight indexing via dlsym throughout Phase B. Phase D removes it. + +- [ ] **Step 3: Cargo build to verify** + +```bash +cargo build -p stint-app 2>&1 | tail -8 +``` + +Expected: three `cargo:warning=… rebuilt at …` lines (framework, widget, intents extension) and a `Finished` line. + +- [ ] **Step 4: Verify the bundle** + +```bash +ls crates/stint-app/PlugIns/ +file crates/stint-app/PlugIns/StintIntentsExtension.appex/Contents/MacOS/StintIntentsExtension +``` + +Expected: both `StintIntentsExtension.appex/` and `StintWidget.appex/` directories. Binary is `Mach-O 64-bit executable arm64`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/stint-app/build.rs +git commit -m "build(6d): build.rs drives StintIntentsExtension.appex production" +``` + +--- + +## Task B6: Bundle StintIntentsExtension.appex into Stint.app via wrapper script + +**Files:** +- Modify: `scripts/build-app-with-widget.sh` + +- [ ] **Step 1: Generalize the wrapper to relocate + sign two .appex bundles** + +Open `scripts/build-app-with-widget.sh`. Replace the entire body of the script with this updated version that handles both extensions: + +```bash +#!/usr/bin/env bash +# Build Stint.app and relocate the embedded extension .appex bundles into +# Contents/PlugIns/ where macOS's WidgetKit + App Intents indexer look for +# them. Tauri's bundle.resources puts files under Contents/Resources/ but +# Apple requires extensions at Contents/PlugIns/.appex. +# +# Phase 6d: ships TWO extensions — StintWidget + StintIntentsExtension. +# +# Usage: +# scripts/build-app-with-widget.sh # ad-hoc sign (local dev install) +# scripts/build-app-with-widget.sh "Developer ID Application: ..." # release sign + +set -euo pipefail + +readonly REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +readonly SIGN_IDENTITY="${1:--}" +readonly APP="target/release/bundle/macos/Stint.app" + +echo "==> Building Stint.app" +cargo tauri build --bundles app + +relocate_appex() { + local name="$1" + local src="crates/stint-app/PlugIns/${name}.appex" + local dest="$APP/Contents/PlugIns/${name}.appex" + if [[ ! -d "$src" ]]; then + echo "ERROR: $src missing — build.rs did not produce $name.appex" + exit 1 + fi + echo "==> Relocating ${name}.appex into Contents/PlugIns/" + mkdir -p "$(dirname "$dest")" + rm -rf "$dest" + cp -R "$src" "$dest" +} + +relocate_appex StintWidget +relocate_appex StintIntentsExtension + +# Strip the Resources/PlugIns duplicate Tauri may leave behind. +rm -rf "$APP/Contents/Resources/PlugIns" + +echo "==> Re-signing embedded StintIntents framework (build.rs ad-hoc only)" +codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \ + "$APP/Contents/Frameworks/StintIntents.framework" + +echo "==> Signing StintWidget.appex with $SIGN_IDENTITY" +codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \ + --entitlements crates/stint-app/swift/Extensions/StintWidget/StintWidget.entitlements \ + "$APP/Contents/PlugIns/StintWidget.appex" + +echo "==> Signing StintIntentsExtension.appex with $SIGN_IDENTITY" +codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \ + --entitlements crates/stint-app/swift/Extensions/StintIntentsExtension/StintIntentsExtension.entitlements \ + "$APP/Contents/PlugIns/StintIntentsExtension.appex" + +echo "==> Re-signing main bundle to seal the new PlugIns/ + Frameworks/" +codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \ + --entitlements crates/stint-app/entitlements.plist \ + "$APP/Contents/MacOS/stint-app" +codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \ + --entitlements crates/stint-app/entitlements.plist \ + "$APP" + +echo "==> Verifying signature" +codesign --verify --deep --strict --verbose=2 "$APP" 2>&1 | tail -3 + +echo "==> Done. Bundle at $APP" +``` + +- [ ] **Step 2: Run the wrapper with ad-hoc sign** + +```bash +scripts/build-app-with-widget.sh 2>&1 | tail -15 +``` + +Expected: both .appex bundles listed in the relocation output; `codesign --verify` passes. + +- [ ] **Step 3: Verify both bundles landed in /Contents/PlugIns/** + +```bash +ls target/release/bundle/macos/Stint.app/Contents/PlugIns/ +``` + +Expected: `StintIntentsExtension.appex StintWidget.appex`. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/build-app-with-widget.sh +git commit -m "build(6d): wrapper script bundles + signs both extension appex bundles" +``` + +--- + +## Task B7: Notarize + install + verify Shortcuts.app discovery + +**Files:** none. + +- [ ] **Step 1: Sign + notarize + staple + install** + +```bash +scripts/build-app-with-widget.sh "Developer ID Application: Reyem Technologies Inc. (WAK5K2758P)" + +APP="target/release/bundle/macos/Stint.app" +ZIP="${APP}.zip" +rm -f "$ZIP" +ditto -c -k --keepParent "$APP" "$ZIP" +xcrun notarytool submit "$ZIP" --keychain-profile "stint-notary" --wait + +xcrun stapler staple "$APP" +killall stint-app 2>/dev/null; sleep 1 +rm -rf /Applications/Stint.app +cp -R "$APP" /Applications/ +xattr -cr /Applications/Stint.app +/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f /Applications/Stint.app +open /Applications/Stint.app +sleep 10 # extra time for siriactionsd to ingest the new stencil +``` + +Expected: notarization `status: Accepted`; staple worked. + +- [ ] **Step 2: Verify pluginkit + Shortcuts.app** + +```bash +pluginkit -m -p com.apple.appintents-extension | grep -i stint +pluginkit -m -p com.apple.widgetkit-extension | grep -i stint +``` + +Expected: both queries list the corresponding `tech.reyem.stint.*` bundle IDs. + +- [ ] **Step 3: Manually verify Shortcuts.app** + +1. Open Shortcuts.app. +2. Click `+` → search "stint". +3. Expect actions: Start Timer / Stop Timer / Current / List Today / Switch Project / Update Entry / etc. +4. Drag "Start Timer" into a new shortcut. The action's parameter UI should render (project picker, description field). + +- [ ] **Step 4: Manually verify Spotlight unchanged** + +The legacy framework still handles Spotlight indexing in Phase B. Verify no regression: + +1. Start a timer in stint with description "phase-b-spotlight-test". +2. ⌘-Space → "phase-b-spotlight-test" → entry result should appear within a few seconds. + +If Spotlight regressed, the most likely cause is that the App Intents Extension also tried to register the same intent types and confused the indexer. Check Console.app filtered by `subsystem:com.apple.appintents` for collision messages. + +- [ ] **Step 5: Commit verification marker** + +```bash +git commit --allow-empty -m "test(6d): Phase B — App Intents Extension discovered by Shortcuts.app + +pluginkit lists tech.reyem.stint.intents under com.apple.appintents- +extension. Shortcuts.app search 'stint' shows the full intent catalog; +parameter pickers render. Legacy framework Spotlight path unchanged." +``` + +--- + +# Phase C — Move SpotlightIndexer + IPC + +Goal: move Spotlight indexing from the legacy in-process framework path to the new App Intents Extension via App Group container + Darwin notifications. End state: mutating an entry in stint-app triggers a Spotlight reindex within ~10 seconds. + +--- + +## Task C1: Add App Group entitlement to host stint-app + +**Files:** +- Modify: `crates/stint-app/entitlements.plist` + +- [ ] **Step 1: Add the App Group key** + +Open `crates/stint-app/entitlements.plist`. Inside the top-level ``, add: + +```xml + com.apple.security.application-groups + + group.tech.reyem.stint + +``` + +The full file should now contain (in addition to whatever's already there) those keys above the closing ``. + +- [ ] **Step 2: Verify XML is well-formed** + +```bash +plutil -lint crates/stint-app/entitlements.plist +``` + +Expected: `OK`. + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/entitlements.plist +git commit -m "feat(6d): host entitlements — App Group for Spotlight IPC" +``` + +--- + +## Task C2: Add App Group entitlement to both extension entitlements + +**Files:** +- Modify: `crates/stint-app/swift/Extensions/StintWidget/StintWidget.entitlements` +- Modify: `crates/stint-app/swift/Extensions/StintIntentsExtension/StintIntentsExtension.entitlements` + +- [ ] **Step 1: Add App Group to widget entitlements** + +Replace the entire contents of `crates/stint-app/swift/Extensions/StintWidget/StintWidget.entitlements` with: + +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.application-groups + + group.tech.reyem.stint + + + +``` + +- [ ] **Step 2: Add App Group to intents extension entitlements** + +Replace the entire contents of `crates/stint-app/swift/Extensions/StintIntentsExtension/StintIntentsExtension.entitlements` with: + +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.application-groups + + group.tech.reyem.stint + + + +``` + +- [ ] **Step 3: Lint both** + +```bash +plutil -lint crates/stint-app/swift/Extensions/StintWidget/StintWidget.entitlements +plutil -lint crates/stint-app/swift/Extensions/StintIntentsExtension/StintIntentsExtension.entitlements +``` + +Expected: `OK` for both. + +- [ ] **Step 4: Commit** + +```bash +git add crates/stint-app/swift/Extensions/StintWidget/StintWidget.entitlements \ + crates/stint-app/swift/Extensions/StintIntentsExtension/StintIntentsExtension.entitlements +git commit -m "feat(6d): App Group entitlement on both extension targets" +``` + +--- + +## Task C3: Write SharedContainerMarker.swift + tests (TDD) + +**Files:** +- Create: `crates/stint-app/swift/StintExtensionsCore/Sources/IPC/SharedContainerMarker.swift` +- Create: `crates/stint-app/swift/StintExtensionsCore/Tests/SharedContainerMarkerTests.swift` + +- [ ] **Step 1: Write the failing test** + +```bash +mkdir -p crates/stint-app/swift/StintExtensionsCore/Sources/IPC +cat > crates/stint-app/swift/StintExtensionsCore/Tests/SharedContainerMarkerTests.swift <<'EOF' +import XCTest +import Foundation +@testable import StintExtensionsCore + +final class SharedContainerMarkerTests: XCTestCase { + var tempDir: URL! + + override func setUp() { + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("marker-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + } + + func testEmptyOnFirstRead() throws { + let marker = SharedContainerMarker(containerOverride: tempDir) + XCTAssertEqual(try marker.drain(), []) + } + + func testAppendThenDrain() throws { + let marker = SharedContainerMarker(containerOverride: tempDir) + try marker.append(SpotlightOp(localUuid: "u1", kind: .entryStarted)) + try marker.append(SpotlightOp(localUuid: "u2", kind: .entryDeleted)) + + let drained = try marker.drain() + XCTAssertEqual(drained.count, 2) + XCTAssertEqual(drained[0].localUuid, "u1") + XCTAssertEqual(drained[0].kind, .entryStarted) + XCTAssertEqual(drained[1].localUuid, "u2") + XCTAssertEqual(drained[1].kind, .entryDeleted) + } + + func testDrainClearsFile() throws { + let marker = SharedContainerMarker(containerOverride: tempDir) + try marker.append(SpotlightOp(localUuid: "u1", kind: .entryStarted)) + _ = try marker.drain() + XCTAssertEqual(try marker.drain(), []) + } +} +EOF +``` + +- [ ] **Step 2: Run the test to confirm it fails (no SharedContainerMarker yet)** + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate >/dev/null +xcodebuild test -scheme StintExtensionsCoreTests -destination 'platform=macOS' -derivedDataPath ./build/derived 2>&1 | tail -5 +cd - +``` + +Expected: compile error — `cannot find 'SharedContainerMarker' in scope`. + +- [ ] **Step 3: Write the implementation** + +```bash +cat > crates/stint-app/swift/StintExtensionsCore/Sources/IPC/SharedContainerMarker.swift <<'EOF' +import Foundation + +/// One pending Spotlight operation queued by the host for the extension to +/// process. `kind` mirrors the legacy `IndexerKind` enum cases from Rust's +/// `stint-core/src/ffi.rs` 1:1 so the existing SpotlightIndexer.delta() +/// machinery can consume them without semantic loss. +public struct SpotlightOp: Codable, Equatable { + public let localUuid: String + public let kind: Kind + + public enum Kind: String, Codable, Equatable { + case entryStarted + case entryStopped + case entryUpdated + case entryDeleted + case projectsReplaced + case tasksReplaced + } + + public init(localUuid: String, kind: Kind) { + self.localUuid = localUuid + self.kind = kind + } +} + +/// Append-only JSON marker file in the App Group shared container. The host +/// appends mutations; the extension drains them on Darwin notification or +/// at next wake. +/// +/// Container path: +/// ~/Library/Group Containers/group.tech.reyem.stint/pending-reindex.json +/// +/// Use `containerOverride` in tests to write to a tempdir instead. +public final class SharedContainerMarker { + public static let appGroupId = "group.tech.reyem.stint" + public static let fileName = "pending-reindex.json" + + private let containerURL: URL + + public init(containerOverride: URL? = nil) { + if let override = containerOverride { + self.containerURL = override + } else if let group = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupId) { + self.containerURL = group + } else { + // App Group not entitled (CLI / dev binary). Fall back to a + // per-process tempdir so calls don't crash; the data won't be + // visible across processes but tests of producer-side behavior + // still work. + self.containerURL = FileManager.default.temporaryDirectory + .appendingPathComponent("stint-marker-fallback") + try? FileManager.default.createDirectory(at: self.containerURL, withIntermediateDirectories: true) + } + } + + private var fileURL: URL { + containerURL.appendingPathComponent(Self.fileName) + } + + /// Append one operation. Atomic via write-temp + rename. + public func append(_ op: SpotlightOp) throws { + var existing = (try? loadOps()) ?? [] + existing.append(op) + try writeOps(existing) + } + + /// Read all pending ops and clear the file. + public func drain() throws -> [SpotlightOp] { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } + let ops = try loadOps() + try writeOps([]) + return ops + } + + private func loadOps() throws -> [SpotlightOp] { + let data = try Data(contentsOf: fileURL) + if data.isEmpty { return [] } + return try JSONDecoder().decode([SpotlightOp].self, from: data) + } + + private func writeOps(_ ops: [SpotlightOp]) throws { + let data = try JSONEncoder().encode(ops) + let tmp = fileURL.appendingPathExtension("tmp") + try data.write(to: tmp, options: .atomic) + _ = try FileManager.default.replaceItemAt(fileURL, withItemAt: tmp) + } +} +EOF +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate >/dev/null +xcodebuild test -scheme StintExtensionsCoreTests -destination 'platform=macOS' -derivedDataPath ./build/derived 2>&1 | tail -8 +cd - +``` + +Expected: `** TEST SUCCEEDED **`, all 3 SharedContainerMarker tests pass alongside the existing 5. + +- [ ] **Step 5: Commit** + +```bash +git add crates/stint-app/swift/StintExtensionsCore/Sources/IPC/ \ + crates/stint-app/swift/StintExtensionsCore/Tests/SharedContainerMarkerTests.swift +git commit -m "feat(6d): SharedContainerMarker — append/drain pending Spotlight ops" +``` + +--- + +## Task C4: Write DarwinNotification.swift + tests (TDD) + +**Files:** +- Create: `crates/stint-app/swift/StintExtensionsCore/Sources/IPC/DarwinNotification.swift` +- Create: `crates/stint-app/swift/StintExtensionsCore/Tests/DarwinNotificationTests.swift` + +- [ ] **Step 1: Write the failing test** + +```bash +cat > crates/stint-app/swift/StintExtensionsCore/Tests/DarwinNotificationTests.swift <<'EOF' +import XCTest +import Foundation +@testable import StintExtensionsCore + +final class DarwinNotificationTests: XCTestCase { + func testPostAndObserveRoundTrip() { + let name = "tech.reyem.stint.test.\(UUID().uuidString)" + let received = expectation(description: "observer fires") + + let token = DarwinNotification.observe(name: name) { + received.fulfill() + } + + DarwinNotification.post(name: name) + wait(for: [received], timeout: 2.0) + + DarwinNotification.removeObserver(token) + } +} +EOF +``` + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate >/dev/null +xcodebuild test -scheme StintExtensionsCoreTests -destination 'platform=macOS' -derivedDataPath ./build/derived 2>&1 | tail -5 +cd - +``` + +Expected: compile error — `cannot find 'DarwinNotification' in scope`. + +- [ ] **Step 3: Write the implementation** + +```bash +cat > crates/stint-app/swift/StintExtensionsCore/Sources/IPC/DarwinNotification.swift <<'EOF' +import Foundation +import CoreFoundation + +/// Thin wrapper around CFNotificationCenter's Darwin notification API. Used +/// to wake the App Intents Extension when the host has new Spotlight work +/// queued in the shared container. +/// +/// The canonical notification name is `tech.reyem.stint.reindex`. +public enum DarwinNotification { + public static let reindexName = "tech.reyem.stint.reindex" + + /// Token returned by `observe` so the caller can pass it to + /// `removeObserver` on teardown. + public final class Token { + let name: CFString + let opaque: UnsafeRawPointer + init(name: CFString, opaque: UnsafeRawPointer) { + self.name = name + self.opaque = opaque + } + } + + /// Post a Darwin notification. Cross-process; no payload. + public static func post(name: String) { + let center = CFNotificationCenterGetDarwinNotifyCenter() + let cfName = name as CFString + CFNotificationCenterPostNotification(center, CFNotificationName(cfName), nil, nil, true) + } + + /// Register an observer. The callback is invoked on the main queue. Returns + /// a token; pass it to `removeObserver` when done. + @discardableResult + public static func observe(name: String, callback: @escaping () -> Void) -> Token { + let center = CFNotificationCenterGetDarwinNotifyCenter() + let cfName = name as CFString + + let box = Box(callback: callback) + let opaque = Unmanaged.passRetained(box).toOpaque() + + CFNotificationCenterAddObserver( + center, + opaque, + { _, observer, _, _, _ in + guard let observer else { return } + let box = Unmanaged.fromOpaque(observer).takeUnretainedValue() + DispatchQueue.main.async { box.callback() } + }, + cfName, + nil, + .deliverImmediately + ) + + return Token(name: cfName, opaque: UnsafeRawPointer(opaque)) + } + + public static func removeObserver(_ token: Token) { + let center = CFNotificationCenterGetDarwinNotifyCenter() + CFNotificationCenterRemoveObserver(center, token.opaque, CFNotificationName(token.name), nil) + Unmanaged.fromOpaque(UnsafeMutableRawPointer(mutating: token.opaque)).release() + } + + private final class Box { + let callback: () -> Void + init(callback: @escaping () -> Void) { self.callback = callback } + } +} +EOF +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate >/dev/null +xcodebuild test -scheme StintExtensionsCoreTests -destination 'platform=macOS' -derivedDataPath ./build/derived 2>&1 | tail -5 +cd - +``` + +Expected: `** TEST SUCCEEDED **`. Total tests now: 5 (legacy) + 3 (SharedContainerMarker) + 1 (DarwinNotification) = 9. + +- [ ] **Step 5: Commit** + +```bash +git add crates/stint-app/swift/StintExtensionsCore/Sources/IPC/DarwinNotification.swift \ + crates/stint-app/swift/StintExtensionsCore/Tests/DarwinNotificationTests.swift +git commit -m "feat(6d): DarwinNotification — post + observe wrapper for host↔extension wakeup" +``` + +--- + +## Task C5: Copy SpotlightIndexer + Focus + ActivityTracker into StintExtensionsCore + +**Files:** +- Create: `crates/stint-app/swift/StintExtensionsCore/Sources/Spotlight/SpotlightIndexer.swift` (copy) +- Create: `crates/stint-app/swift/StintExtensionsCore/Sources/Spotlight/ActivityTracker.swift` (copy) +- Create: `crates/stint-app/swift/StintExtensionsCore/Sources/Focus/ProjectFocusFilter.swift` (copy) +- Create: `crates/stint-app/swift/StintExtensionsCore/Sources/Init/StintIntentsInit.swift` (copy) + +- [ ] **Step 1: Copy the files** + +```bash +SRC=crates/stint-app/swift/StintIntents/Sources/StintIntents +DST=crates/stint-app/swift/StintExtensionsCore/Sources + +mkdir -p $DST/Spotlight $DST/Focus $DST/Init +cp $SRC/Spotlight/SpotlightIndexer.swift $DST/Spotlight/ +cp $SRC/Spotlight/ActivityTracker.swift $DST/Spotlight/ +cp $SRC/Focus/ProjectFocusFilter.swift $DST/Focus/ +cp $SRC/Init/StintIntentsInit.swift $DST/Init/ +``` + +- [ ] **Step 2: Regenerate + build the framework** + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate 2>&1 | tail -3 +xcodebuild build -scheme StintExtensionsCore -configuration Release -destination 'platform=macOS' -derivedDataPath ./build/derived 2>&1 | tail -5 +cd - +``` + +Expected: `** BUILD SUCCEEDED **`. + +If compile errors appear (e.g. missing `Bridge` references), the most likely cause is that `SpotlightIndexer.swift` imports something from the legacy `Bridge.swift` that needs to use the new `Bridge/RustFFI.swift`. Open the failing file, change the symbol references to match the renamed module location (the Swift code is the same; only the file path changed). + +- [ ] **Step 3: Commit** + +```bash +git add crates/stint-app/swift/StintExtensionsCore/Sources/{Spotlight,Focus,Init}/ +git commit -m "chore(6d): copy SpotlightIndexer + Focus + Init into StintExtensionsCore" +``` + +--- + +## Task C6: Write Rust spotlight_ipc helper + tests (TDD) + +**Files:** +- Create: `crates/stint-app/src/spotlight_ipc.rs` +- Create: `crates/stint-app/tests/spotlight_ipc.rs` +- Modify: `crates/stint-app/src/lib.rs` + +- [ ] **Step 1: Write the failing integration test** + +```bash +cat > crates/stint-app/tests/spotlight_ipc.rs <<'EOF' +//! Integration test for the Rust-side Spotlight IPC helper. Uses +//! STINT_APP_GROUP_OVERRIDE_DIR to redirect writes to a tempdir so the +//! real App Group container isn't touched. + +use std::env; +use stint_app::spotlight_ipc::{push_pending, SpotlightOp}; +use tempfile::TempDir; + +struct EnvRestore { + key: String, + prev: Option, +} +impl EnvRestore { + fn set(key: &str, value: &str) -> Self { + let prev = env::var(key).ok(); + env::set_var(key, value); + Self { key: key.into(), prev } + } +} +impl Drop for EnvRestore { + fn drop(&mut self) { + match &self.prev { + Some(v) => env::set_var(&self.key, v), + None => env::remove_var(&self.key), + } + } +} + +#[test] +fn push_pending_writes_marker_file() { + let dir = TempDir::new().unwrap(); + let _guard = EnvRestore::set("STINT_APP_GROUP_OVERRIDE_DIR", dir.path().to_str().unwrap()); + + push_pending("uuid-A", SpotlightOp::EntryStarted).unwrap(); + push_pending("uuid-B", SpotlightOp::EntryDeleted).unwrap(); + + let marker_path = dir.path().join("pending-reindex.json"); + assert!(marker_path.exists()); + let content = std::fs::read_to_string(marker_path).unwrap(); + assert!(content.contains("uuid-A")); + assert!(content.contains("uuid-B")); + assert!(content.contains("entryStarted")); + assert!(content.contains("entryDeleted")); +} +EOF +``` + +- [ ] **Step 2: Run, confirm fail** + +```bash +cargo test -p stint-app --test spotlight_ipc 2>&1 | tail -5 +``` + +Expected: compile error — `stint_app::spotlight_ipc` not found. + +- [ ] **Step 3: Implement the helper** + +Create `crates/stint-app/src/spotlight_ipc.rs`: + +```rust +//! Host → App Intents Extension IPC for Spotlight reindex. +//! +//! Replaces the in-process dlsym path that the legacy StintIntents +//! framework used. Verb call sites push pending ops here; the extension +//! drains them on its next wake. +//! +//! Storage shape mirrors Swift's `SharedContainerMarker` in +//! `StintExtensionsCore/Sources/IPC/SharedContainerMarker.swift`. + +use std::env; +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +const APP_GROUP_ID: &str = "group.tech.reyem.stint"; +const FILE_NAME: &str = "pending-reindex.json"; +const DARWIN_NOTIFICATION: &str = "tech.reyem.stint.reindex"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SpotlightOp { + EntryStarted, + EntryStopped, + EntryUpdated, + EntryDeleted, + ProjectsReplaced, + TasksReplaced, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PendingEntry { + #[serde(rename = "localUuid")] + local_uuid: String, + kind: SpotlightOp, +} + +fn container_dir() -> PathBuf { + if let Ok(override_dir) = env::var("STINT_APP_GROUP_OVERRIDE_DIR") { + return PathBuf::from(override_dir); + } + // ~/Library/Group Containers// + let home = env::var("HOME").unwrap_or_default(); + PathBuf::from(home) + .join("Library/Group Containers") + .join(APP_GROUP_ID) +} + +/// Append a pending op to the shared container marker file and post the +/// Darwin notification so the extension wakes up eagerly. Best-effort: +/// errors are returned but call sites typically log-and-continue. +pub fn push_pending(local_uuid: &str, op: SpotlightOp) -> std::io::Result<()> { + let dir = container_dir(); + fs::create_dir_all(&dir)?; + let path = dir.join(FILE_NAME); + + let mut entries: Vec = if path.exists() { + let data = fs::read(&path)?; + if data.is_empty() { + Vec::new() + } else { + serde_json::from_slice(&data).unwrap_or_default() + } + } else { + Vec::new() + }; + entries.push(PendingEntry { + local_uuid: local_uuid.into(), + kind: op, + }); + + let json = serde_json::to_vec(&entries) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + let tmp = path.with_extension("tmp"); + fs::write(&tmp, &json)?; + fs::rename(&tmp, &path)?; + + #[cfg(target_os = "macos")] + post_darwin_notification(); + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn post_darwin_notification() { + use core_foundation::base::TCFType; + use core_foundation::notification_center::CFNotificationCenter; + use core_foundation::string::CFString; + + let name = CFString::new(DARWIN_NOTIFICATION); + let center = CFNotificationCenter::darwin_notify_center(); + center.post_notification(name, None::<&CFString>, false); +} + +#[cfg(not(target_os = "macos"))] +fn post_darwin_notification() {} +``` + +- [ ] **Step 4: Add core-foundation dep to Cargo.toml** + +Open `crates/stint-app/Cargo.toml`. In the `[dependencies]` section, add: + +```toml +core-foundation = "0.10" +``` + +(Or whatever the latest 0.x is — check `cargo search core-foundation` if needed.) + +- [ ] **Step 5: Wire the module into lib.rs** + +Open `crates/stint-app/src/lib.rs`. Add a new line in alphabetical order with the other `pub mod` declarations: + +```rust +pub mod spotlight_ipc; +``` + +- [ ] **Step 6: Run the test to verify it passes** + +```bash +cargo test -p stint-app --test spotlight_ipc 2>&1 | tail -5 +``` + +Expected: `test result: ok. 1 passed`. + +- [ ] **Step 7: Commit** + +```bash +git add crates/stint-app/src/spotlight_ipc.rs \ + crates/stint-app/src/lib.rs \ + crates/stint-app/Cargo.toml \ + crates/stint-app/Cargo.lock \ + crates/stint-app/tests/spotlight_ipc.rs +git commit -m "feat(6d): spotlight_ipc helper — append + Darwin post for extension wakeup" +``` + +--- + +## Task C7: Replace stint-core's notify_indexer with spotlight_ipc + +**Files:** +- Modify: `crates/stint-core/src/ffi.rs` + +The existing `notify_indexer()` in stint-core/src/ffi.rs uses dlsym to call into the Swift framework. Replace its body to write the marker file + post the Darwin notification instead. The function signature stays the same; the verb call sites don't change. + +- [ ] **Step 1: Replace the notify_indexer implementation** + +Open `crates/stint-core/src/ffi.rs`. Find `pub fn notify_indexer(kind: IndexerKind, payload_json: &str)` (around line 520). Replace its body so it writes to the App Group container instead of dlsym'ing. + +```rust +/// Notify the Spotlight indexer about a mutation. As of Phase 6d this +/// writes to the App Group shared container at +/// `~/Library/Group Containers/group.tech.reyem.stint/pending-reindex.json` +/// and posts a Darwin notification. The App Intents Extension wakes on +/// the notification (or at its next scheduled wake) and drains the +/// pending ops into Spotlight's index. +/// +/// Best-effort: errors are silently swallowed (CLI / headless / no +/// container entitlement). Call sites in the verbs façade don't need to +/// change. +pub fn notify_indexer(kind: IndexerKind, payload_json: &str) { + // Extract local_uuid from the payload JSON for the marker file. Verbs + // that mutate a single entry pass {"local_uuid": "..."}-shaped payloads; + // ProjectsReplaced / TasksReplaced pass payloads without a UUID and we + // record a sentinel. + let local_uuid = serde_json::from_str::(payload_json) + .ok() + .and_then(|v| { + v.get("local_uuid") + .and_then(|s| s.as_str()) + .map(String::from) + }) + .unwrap_or_default(); + + let op = match kind { + IndexerKind::EntryStarted => "entryStarted", + IndexerKind::EntryStopped => "entryStopped", + IndexerKind::EntryUpdated => "entryUpdated", + IndexerKind::EntryDeleted => "entryDeleted", + IndexerKind::ProjectsReplaced => "projectsReplaced", + IndexerKind::TasksReplaced => "tasksReplaced", + }; + + let _ = append_pending(&local_uuid, op); + + #[cfg(target_os = "macos")] + { + let name = "tech.reyem.stint.reindex\0"; + unsafe { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFStringCreateWithCString( + std::ptr::null(), + name.as_ptr() as *const c_char, + 0x08000100, // kCFStringEncodingUTF8 + ), + std::ptr::null(), + std::ptr::null(), + 1, // deliverImmediately + ); + } + } +} + +fn append_pending(local_uuid: &str, op: &str) -> std::io::Result<()> { + use std::fs; + use std::path::PathBuf; + + let home = std::env::var("HOME").unwrap_or_default(); + let dir = PathBuf::from(home) + .join("Library/Group Containers/group.tech.reyem.stint"); + fs::create_dir_all(&dir)?; + let path = dir.join("pending-reindex.json"); + + let mut entries: Vec = if path.exists() { + let data = fs::read(&path)?; + if data.is_empty() { + Vec::new() + } else { + serde_json::from_slice(&data).unwrap_or_default() + } + } else { + Vec::new() + }; + entries.push(serde_json::json!({ + "localUuid": local_uuid, + "kind": op, + })); + let data = serde_json::to_vec(&entries).map_err(std::io::Error::other)?; + let tmp = path.with_extension("tmp"); + fs::write(&tmp, &data)?; + fs::rename(&tmp, &path)?; + Ok(()) +} +``` + +- [ ] **Step 2: Add the CFNotificationCenter extern decl** + +Near the top of `crates/stint-core/src/ffi.rs`, find where `extern "C"` blocks live. Add: + +```rust +#[cfg(target_os = "macos")] +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFNotificationCenterGetDarwinNotifyCenter() -> *mut std::ffi::c_void; + fn CFNotificationCenterPostNotification( + center: *mut std::ffi::c_void, + name: *mut std::ffi::c_void, + object: *const std::ffi::c_void, + user_info: *const std::ffi::c_void, + deliver_immediately: i32, + ); + fn CFStringCreateWithCString( + alloc: *const std::ffi::c_void, + cstr: *const c_char, + encoding: u32, + ) -> *mut std::ffi::c_void; +} +``` + +If `c_char` isn't already imported at the top of the file, add `use std::ffi::c_char;` to the imports. + +- [ ] **Step 3: Remove the now-dead dlsym lookup code** + +In the same file, delete the entire `lookup_indexer_notify()` function, the `INDEXER_NOTIFY_SYMBOL: OnceLock<...>` static, and the `IndexerNotifyFn` typedef. These were only used by the old dlsym path. + +- [ ] **Step 4: Build the workspace** + +```bash +cargo build --workspace 2>&1 | tail -5 +``` + +Expected: `Finished` (no errors). + +- [ ] **Step 5: Run all stint-core tests** + +```bash +cargo test -p stint-core -- --test-threads=1 2>&1 | tail -5 +``` + +Expected: all green (the verb tests should still pass — they don't depend on the dlsym path). + +- [ ] **Step 6: Commit** + +```bash +git add crates/stint-core/src/ffi.rs +git commit -m "feat(6d): stint-core notify_indexer writes App Group marker + Darwin post + +Replaces the in-process dlsym call into the StintIntents Swift framework +with cross-process IPC: append to ~/Library/Group Containers/group.tech. +reyem.stint/pending-reindex.json, then post the +tech.reyem.stint.reindex Darwin notification so the App Intents +Extension wakes eagerly. + +Verb call sites don't change — same function signature, same enum." +``` + +--- + +## Task C8: Write ExtensionLifecycle.swift — extension-side drain loop + +**Files:** +- Create: `crates/stint-app/swift/Extensions/StintIntentsExtension/Sources/ExtensionLifecycle.swift` + +This file installs the Darwin observer at extension launch and drains the marker file via SpotlightIndexer. Apple wakes the extension on its own schedule too, so the observer is best-effort eagerness. + +- [ ] **Step 1: Create the source file** + +```bash +cat > crates/stint-app/swift/Extensions/StintIntentsExtension/Sources/ExtensionLifecycle.swift <<'EOF' +import Foundation +import StintExtensionsCore + +/// Module initializer that runs the first time the extension binary is +/// loaded. Registers the Darwin observer and drains any pending ops that +/// accumulated while we were asleep. +/// +/// Swift doesn't have a `dyld constructor` story for executable targets, +/// so we use a static `let` whose initializer side-effects do the +/// registration. AppIntentsExtension's @main bootstrap touches the type +/// during launch, which triggers this initializer. +public enum ExtensionLifecycle { + public static let _bootstrap: Void = { + // Drain whatever's already queued at launch. + drainPending() + + // Register the Darwin notification observer; calls drainPending() + // again on each post. + DarwinNotification.observe(name: DarwinNotification.reindexName) { + drainPending() + } + }() + + private static func drainPending() { + let marker = SharedContainerMarker() + let ops = (try? marker.drain()) ?? [] + guard !ops.isEmpty else { return } + + let indexer = SpotlightIndexer() + for op in ops { + indexer.apply(op) + } + } +} +EOF +``` + +- [ ] **Step 2: Wire the bootstrap touch into IntentsExtensionMain.swift** + +Update `crates/stint-app/swift/Extensions/StintIntentsExtension/Sources/IntentsExtensionMain.swift` to: + +```bash +cat > crates/stint-app/swift/Extensions/StintIntentsExtension/Sources/IntentsExtensionMain.swift <<'EOF' +import AppIntents +import StintExtensionsCore + +@main +struct StintAppIntentsExtension: AppIntentsExtension { + init() { + // Touch the bootstrap to trigger Darwin observer registration + + // drain of any pending Spotlight ops. + _ = ExtensionLifecycle._bootstrap + } +} +EOF +``` + +- [ ] **Step 3: Add the apply(_ op:) method to SpotlightIndexer** + +`SpotlightIndexer.swift` was copied from the legacy framework in Task C5. The legacy class has `delta(kind:payload:)`-style methods. Add a thin adapter that accepts the new `SpotlightOp` shape. Open `crates/stint-app/swift/StintExtensionsCore/Sources/Spotlight/SpotlightIndexer.swift` and append (inside the class): + +```swift + /// Phase 6d entry point: apply one queued op pulled from the App + /// Group marker file. Adapts the new `SpotlightOp` shape to the + /// existing `delta(kind:payload:)` API. + public func apply(_ op: SpotlightOp) { + let kind: IndexerKind + switch op.kind { + case .entryStarted: kind = .entryStarted + case .entryStopped: kind = .entryStopped + case .entryUpdated: kind = .entryUpdated + case .entryDeleted: kind = .entryDeleted + case .projectsReplaced: kind = .projectsReplaced + case .tasksReplaced: kind = .tasksReplaced + } + let payload = #"{"local_uuid":"\#(op.localUuid)"}"# + self.delta(kind: kind, payload: payload) + } +``` + +If `SpotlightIndexer` is a struct (not a class), or if `delta(kind:payload:)` has a different name/signature in your copy, adapt accordingly — the goal is to route one queued op into the existing indexer machinery. + +- [ ] **Step 4: Regenerate + build the extension** + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate >/dev/null +xcodebuild build -scheme StintIntentsExtension -configuration Release -destination 'platform=macOS' -derivedDataPath ./build/derived 2>&1 | tail -5 +cd - +``` + +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/stint-app/swift/Extensions/StintIntentsExtension/Sources/ \ + crates/stint-app/swift/StintExtensionsCore/Sources/Spotlight/SpotlightIndexer.swift +git commit -m "feat(6d): ExtensionLifecycle drains marker + observes Darwin reindex" +``` + +--- + +## Task C9: Manual smoke — verify cross-process Spotlight reindex + +**Files:** none. + +- [ ] **Step 1: Build + notarize + install** + +```bash +scripts/build-app-with-widget.sh "Developer ID Application: Reyem Technologies Inc. (WAK5K2758P)" + +APP="target/release/bundle/macos/Stint.app" +ZIP="${APP}.zip" +rm -f "$ZIP" +ditto -c -k --keepParent "$APP" "$ZIP" +xcrun notarytool submit "$ZIP" --keychain-profile "stint-notary" --wait +xcrun stapler staple "$APP" + +killall stint-app 2>/dev/null; sleep 1 +rm -rf /Applications/Stint.app +cp -R "$APP" /Applications/ +xattr -cr /Applications/Stint.app +/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f /Applications/Stint.app +open /Applications/Stint.app +sleep 5 +``` + +- [ ] **Step 2: Trigger an entry mutation + watch the marker file** + +```bash +# In one terminal — tail the marker file +watch -n 1 'ls -la ~/Library/Group\ Containers/group.tech.reyem.stint/ 2>&1; echo "---"; cat ~/Library/Group\ Containers/group.tech.reyem.stint/pending-reindex.json 2>&1' +``` + +In a second terminal, start a timer with a unique description: + +```bash +/Applications/Stint.app/Contents/MacOS/stint start --description "phase-c-smoke-$(date +%s)" +``` + +Expected (in the watch terminal): within ~1s, `pending-reindex.json` appears with one entry. Within ~10s, it's drained (file becomes `[]`). + +- [ ] **Step 3: Verify Spotlight surfaces the new entry** + +After 10s: ⌘-Space, search the unique description string. Expect the stint entry result to appear. + +If it doesn't appear within 30s: +- Check Console.app filtered by `process:StintIntentsExtension` for crashes. +- Check that the extension is actually running: `pgrep -lf StintIntentsExtension` (it may not be — Apple wakes extensions on demand). Touch the App Intents indexer manually: `xcrun appintents`-style trigger isn't documented, but opening Shortcuts.app and searching "stint" usually triggers a wake. + +- [ ] **Step 4: Stop the timer + verify update propagates** + +```bash +/Applications/Stint.app/Contents/MacOS/stint stop +``` + +Then mutate description via the GUI (or via `stint update`): + +```bash +# Get the local UUID of the last entry +LAST=$(/Applications/Stint.app/Contents/MacOS/stint list --limit 1 --json | jq -r '.[0].local_uuid') +/Applications/Stint.app/Contents/MacOS/stint update "$LAST" --description "phase-c-smoke-updated" +``` + +Within ~10s, Spotlight search for `phase-c-smoke-updated` should return the entry. + +- [ ] **Step 5: Commit verification marker** + +```bash +git commit --allow-empty -m "test(6d): Phase C — cross-process Spotlight reindex works + +Mutating an entry in stint-app writes a marker to ~/Library/Group +Containers/group.tech.reyem.stint/pending-reindex.json and posts the +tech.reyem.stint.reindex Darwin notification. StintIntentsExtension +wakes on the notification, drains the marker, and updates Spotlight's +index. Verified end-to-end: new entry surfaces in Spotlight within +~10 seconds of creation." +``` + +--- + +# Phase D — Retire the legacy framework + +Goal: delete `swift/StintIntents/` and `swift/StintWidget/` SPM packages, remove the framework build path from `build.rs`, remove the `init_stint_intents()` dlsym scaffolding from `main.rs`, and update CI + docs accordingly. End state: only the xcodegen path produces Swift artifacts. + +--- + +## Task D1: Remove init_stint_intents dlsym scaffolding from main.rs + +**Files:** +- Modify: `crates/stint-app/src/main.rs` + +- [ ] **Step 1: Remove the call site in setup()** + +Open `crates/stint-app/src/main.rs`. Find the block containing `init_stint_intents();` (around line 104 of the current code, inside the setup closure). Delete that line AND the preceding comment block (the multi-line `//` comment about "the framework exports stint_intents_init..."). + +- [ ] **Step 2: Remove the function definition** + +In the same file, find `fn init_stint_intents()` (the function itself, with its doc comment). Delete the entire function. Search for any other references to `init_stint_intents`; there should be none after Step 1. + +- [ ] **Step 3: Verify build still works** + +```bash +cargo build -p stint-app 2>&1 | tail -5 +``` + +Expected: `Finished` with no errors. The framework appex is still being built by build.rs (we haven't touched that yet); it's just not initialized at startup. + +- [ ] **Step 4: Commit** + +```bash +git add crates/stint-app/src/main.rs +git commit -m "refactor(6d): remove init_stint_intents dlsym scaffolding from main.rs + +Phase 6d's IPC path doesn't need a framework init; the App Intents +Extension self-bootstraps via @main. Spotlight indexing IPC runs through +spotlight_ipc / stint-core::ffi::notify_indexer (Darwin notification +path) instead of the dlsym-into-framework call this used to perform." +``` + +--- + +## Task D2: Remove build_stint_intents_framework from build.rs + +**Files:** +- Modify: `crates/stint-app/build.rs` + +- [ ] **Step 1: Remove the function and its call site** + +Open `crates/stint-app/build.rs`. Find the `fn build_stint_intents_framework()` function with its long doc comment block. Delete the entire function (everything from the `///` block through the closing `}`). + +Then in `main()`, delete the line: + +```rust + if let Err(e) = build_stint_intents_framework() { + println!("cargo:warning=StintIntents framework build skipped: {e}"); + } +``` + +Final `main()` should be: + +```rust +fn main() { + if let Err(e) = build_stint_widget() { + println!("cargo:warning=StintWidget appex build skipped: {e}"); + } + if let Err(e) = build_stint_intents_extension() { + println!("cargo:warning=StintIntentsExtension appex build skipped: {e}"); + } + tauri_build::build() +} +``` + +- [ ] **Step 2: Remove now-dead helper functions if unused** + +`patch_info_plist()` was only called by `build_stint_intents_framework()`. Search: + +```bash +grep -c "patch_info_plist" crates/stint-app/build.rs +``` + +If the count is 1 (just the definition, no call site), delete the function. + +`copy_dir()` and `codesign_adhoc()` are still used by `build_stint_widget()` and `build_stint_intents_extension()`. Leave them. + +- [ ] **Step 3: Build to verify** + +```bash +cargo build -p stint-app 2>&1 | tail -5 +``` + +Expected: `Finished`. Two appex builds run (widget + intents extension). No framework build runs. + +- [ ] **Step 4: Verify the framework is no longer rebuilt** + +```bash +ls crates/stint-app/Frameworks/ 2>/dev/null +``` + +The directory may still exist with a stale `StintIntents.framework/` from a previous build — that's fine; nothing references it. + +- [ ] **Step 5: Commit** + +```bash +git add crates/stint-app/build.rs +git commit -m "build(6d): remove build_stint_intents_framework from build.rs" +``` + +--- + +## Task D3: Remove StintIntents.framework from tauri.conf.json bundle + +**Files:** +- Modify: `crates/stint-app/tauri.conf.json` + +- [ ] **Step 1: Remove the framework entry** + +Open `crates/stint-app/tauri.conf.json`. Find the `bundle.macOS.frameworks` array. Remove the `"Frameworks/StintIntents.framework"` string. If that's the only entry, change the array to an empty `[]`. + +Also remove the two `bundle.resources` entries that reference the framework's Metadata.appintents stencil: + +```json +"Frameworks/StintIntents.framework/Versions/A/Resources/Metadata.appintents/version.json": "Metadata.appintents/version.json", +"Frameworks/StintIntents.framework/Versions/A/Resources/Metadata.appintents/extract.actionsdata": "Metadata.appintents/extract.actionsdata", +``` + +Keep the man-page entry and any other unrelated resources. + +- [ ] **Step 2: Lint the JSON** + +```bash +python3 -m json.tool crates/stint-app/tauri.conf.json >/dev/null && echo "valid JSON" +``` + +Expected: `valid JSON`. + +- [ ] **Step 3: Rebuild + verify bundle** + +```bash +scripts/build-app-with-widget.sh 2>&1 | tail -5 +ls target/release/bundle/macos/Stint.app/Contents/Frameworks/ 2>&1 +``` + +Expected: `Frameworks/` directory may not exist at all, or contains nothing related to StintIntents. + +- [ ] **Step 4: Commit** + +```bash +git add crates/stint-app/tauri.conf.json +git commit -m "build(6d): remove StintIntents.framework from tauri.conf.json bundle" +``` + +--- + +## Task D4: Update wrapper script to drop framework re-sign step + +**Files:** +- Modify: `scripts/build-app-with-widget.sh` + +- [ ] **Step 1: Remove the framework re-sign block** + +Open `scripts/build-app-with-widget.sh`. Find and delete the block: + +```bash +echo "==> Re-signing embedded StintIntents framework (build.rs ad-hoc only)" +codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \ + "$APP/Contents/Frameworks/StintIntents.framework" +``` + +- [ ] **Step 2: Verify the wrapper still passes** + +```bash +scripts/build-app-with-widget.sh 2>&1 | tail -10 +``` + +Expected: `codesign --verify` passes; no `StintIntents.framework` mentioned in output. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/build-app-with-widget.sh +git commit -m "build(6d): wrapper script no longer re-signs deleted StintIntents framework" +``` + +--- + +## Task D5: Update release-artifacts.yml — drop framework signing + verify + +**Files:** +- Modify: `.github/workflows/release-artifacts.yml` + +- [ ] **Step 1: Remove framework-specific steps** + +Open `.github/workflows/release-artifacts.yml`. Find and delete: + +- The entire `- name: Verify StintIntents framework + App Intents metadata` step (around line 196). +- The `codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" "$APP_PATH/Contents/Frameworks/StintIntents.framework"` line inside the `Sign GUI binary + .app bundle` step. +- The `codesign --verify --strict --verbose=2 "$APP_PATH/Contents/Frameworks/StintIntents.framework"` line at the bottom of the same step. + +- [ ] **Step 2: Add a sign step for the new StintIntentsExtension.appex** + +Find the existing `codesign … StintWidget.appex` step block (added at end of 6c). Right after it, add: + +```yaml + # Sign the App Intents Extension .appex (Phase 6d). Same + # entitlement requirement as the widget: sandbox + App Group. + codesign --force --options runtime --timestamp \ + --sign "$APPLE_SIGNING_IDENTITY" \ + --entitlements crates/stint-app/swift/Extensions/StintIntentsExtension/StintIntentsExtension.entitlements \ + "$APP_PATH/Contents/PlugIns/StintIntentsExtension.appex" +``` + +Also add a verify line at the bottom of the codesign verify block: + +```yaml + codesign --verify --strict --verbose=2 "$APP_PATH/Contents/PlugIns/StintIntentsExtension.appex" +``` + +- [ ] **Step 3: Add the relocation step for the new appex** + +Find the `Relocate StintWidget.appex into Contents/PlugIns/` step (added in 6c). Update its run script to relocate BOTH appex bundles: + +```yaml + - name: Relocate extension .appex bundles into Contents/PlugIns/ + run: | + for name in StintWidget StintIntentsExtension; do + SRC="crates/stint-app/PlugIns/${name}.appex" + DEST="$APP_PATH/Contents/PlugIns/${name}.appex" + if [ ! -d "$SRC" ]; then echo "::error::$SRC missing"; exit 1; fi + mkdir -p "$(dirname "$DEST")" + rm -rf "$DEST" + cp -R "$SRC" "$DEST" + done + rm -rf "$APP_PATH/Contents/Resources/PlugIns" +``` + +- [ ] **Step 4: Lint the YAML** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release-artifacts.yml'))" && echo "valid" +``` + +Expected: `valid`. + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/release-artifacts.yml +git commit -m "ci(6d): release pipeline signs both extension appex bundles, drops framework" +``` + +--- + +## Task D6: Update ci.yml — remove legacy framework + SPM widget test steps + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Delete the legacy framework + widget test steps** + +Open `.github/workflows/ci.yml`. Find and delete: + +```yaml + - name: Swift test (StintIntents framework) + working-directory: crates/stint-app/swift/StintIntents + run: xcodebuild -scheme StintIntents -destination 'platform=macOS' -derivedDataPath ./build/derived test + + - name: Swift test (StintWidget) + working-directory: crates/stint-app/swift/StintWidget + run: xcodebuild -scheme StintWidget -destination 'platform=macOS' -derivedDataPath ./build/derived test +``` + +The `Swift test (StintExtensionsCore)` step added in Task A12 already covers both — the consolidated test target tests the framework that both extensions link. + +- [ ] **Step 2: Lint the YAML** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" && echo "valid" +``` + +Expected: `valid`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci(6d): drop SPM-based Swift test steps; StintExtensionsCore covers both" +``` + +--- + +## Task D7: Delete the legacy SPM Swift packages + +**Files:** +- Delete: `crates/stint-app/swift/StintIntents/` (entire directory) +- Delete: `crates/stint-app/swift/StintWidget/` (entire directory) + +- [ ] **Step 1: Confirm no remaining references** + +```bash +grep -rn "swift/StintIntents/\|swift/StintWidget/" \ + crates/stint-app/build.rs \ + crates/stint-app/tauri.conf.json \ + scripts/build-app-with-widget.sh \ + .github/workflows/ 2>/dev/null +``` + +Expected: no matches. If any reference remains, fix it before deleting. + +- [ ] **Step 2: Delete both directories** + +```bash +git rm -r crates/stint-app/swift/StintIntents crates/stint-app/swift/StintWidget +``` + +- [ ] **Step 3: Verify cargo build still succeeds** + +```bash +cargo build -p stint-app 2>&1 | tail -5 +``` + +Expected: `Finished`. Two `cargo:warning=… appex rebuilt at …` lines (widget + intents). No mentions of the deleted SPM packages. + +- [ ] **Step 4: Commit** + +```bash +git commit -m "chore(6d): delete legacy StintIntents + StintWidget SPM packages + +Both replaced by the xcodegen-driven targets in +crates/stint-app/swift/xcodegen/project.yml. Spotlight indexing now runs +in the App Intents Extension via App Group container + Darwin +notification IPC (Phase C); the framework path is fully retired." +``` + +--- + +## Task D8: Full workspace verification + +**Files:** none. + +- [ ] **Step 1: Format + lint + Rust tests** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -5 +cargo test --workspace -- --test-threads=1 2>&1 | grep -E "^test result|FAILED" | tail -10 +``` + +Expected: fmt clean, no clippy warnings, every test binary green. + +- [ ] **Step 2: UI typecheck + tests** + +```bash +cd ui && pnpm typecheck && pnpm vitest run 2>&1 | grep -E "Test Files|Tests " | tail -3 +cd .. +``` + +Expected: typecheck clean; all 271+ tests pass. + +- [ ] **Step 3: Swift test against StintExtensionsCore** + +```bash +cd crates/stint-app/swift/xcodegen +xcodegen generate >/dev/null +xcodebuild test -scheme StintExtensionsCoreTests -destination 'platform=macOS' -derivedDataPath ./build/derived 2>&1 | tail -5 +cd - +``` + +Expected: `** TEST SUCCEEDED **`, ~9 tests pass (5 legacy + 3 SharedContainerMarker + 1 DarwinNotification). + +- [ ] **Step 4: Coverage** + +```bash +scripts/coverage.sh 2>&1 | tail -15 +``` + +Expected: green across all surfaces, each ≥ 80% lines. + +- [ ] **Step 5: Commit verification marker** + +```bash +git commit --allow-empty -m "test(6d): full workspace verification — fmt/clippy/tests/coverage all green" +``` + +--- + +## Task D9: Manual smoke — full spec §7 checklist + +**Files:** none. + +Run through every item from the spec's §7 manual smoke list: + +- [ ] **Step 1: Notarized install** + +```bash +scripts/build-app-with-widget.sh "Developer ID Application: Reyem Technologies Inc. (WAK5K2758P)" +APP="target/release/bundle/macos/Stint.app" +rm -f "${APP}.zip"; ditto -c -k --keepParent "$APP" "${APP}.zip" +xcrun notarytool submit "${APP}.zip" --keychain-profile "stint-notary" --wait +xcrun stapler staple "$APP" +killall stint-app 2>/dev/null; sleep 1 +rm -rf /Applications/Stint.app +cp -R "$APP" /Applications/ +xattr -cr /Applications/Stint.app +/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f /Applications/Stint.app +open /Applications/Stint.app +sleep 10 +``` + +- [ ] **Step 2: Verify pluginkit registration** + +```bash +pluginkit -m -p com.apple.widgetkit-extension | grep -i stint +pluginkit -m -p com.apple.appintents-extension | grep -i stint +``` + +Expected: both queries list `tech.reyem.stint.widget(1.0)` and `tech.reyem.stint.intents(1.0)` respectively. + +- [ ] **Step 3: Manual checks (record output in the marker commit)** + +For each item below, verify and note the result: + +1. Edit Widgets gallery shows Stint with three configurations × two sizes — ✅ / ❌ +2. Drop the Running Timer Small onto desktop, it renders ✅ / ❌ +3. Shortcuts.app search "stint" lists actions (Start, Stop, Current, etc.) — ✅ / ❌ +4. Siri: "start tracking in Stint" begins a timer — ✅ / ❌ +5. System Settings → Focus → pick a focus → Add Filter → Stint filter visible with project picker — ✅ / ❌ +6. Spotlight: start timer with description "phase-d-spotlight", wait 10s, ⌘-Space search → entry appears — ✅ / ❌ +7. Idle detection still works (existing 6c feature, not regressed) — ✅ / ❌ +8. Raycast extension still works (6c, not regressed) — ✅ / ❌ + +- [ ] **Step 4: Commit verification marker with results** + +```bash +git commit --allow-empty -m "test(6d): Phase D manual smoke — all 8 spec §7 checks pass + +[Paste the 8-item ✅/❌ list from Step 3 here.] + +End-state of Phase 6d reached: both 6b-deferred (Siri/Shortcuts/Focus) +and 6c-deferred (widget gallery) surfaces are live." +``` + +--- + +## Task D10: Update docs + roadmap + +**Files:** +- Modify: `README.md` +- Modify: `CLAUDE.md` +- Modify: `crates/stint-cli/skills/stint/SKILL.md` + +- [ ] **Step 1: README roadmap row** + +Update the 6b row (still partial today) and add a 6d row: + +``` +| 6b | Spotlight + App Intents + Focus filter | ✅ shipped (via 6d migration) | +| 6c | Raycast + Alfred + WidgetKit + idle detection | ✅ shipped (via 6d migration for WidgetKit) | +| 6d | Xcode-based extensions: full Siri/Shortcuts/Focus + working widget gallery | ✅ shipped | +``` + +- [ ] **Step 2: CLAUDE.md roadmap row** + +Same updates in the table in CLAUDE.md. + +- [ ] **Step 3: SKILL.md — flip "NOT YET LIVE" to live** + +Open `crates/stint-cli/skills/stint/SKILL.md`. Find the App Intents bullet that says "NOT YET LIVE" and replace with: + +```markdown +- **App Intents (Siri / Shortcuts.app / Focus filter)** — live as of Phase 6d. Say "start tracking in Stint" to Siri; build shortcuts in Shortcuts.app; configure System Settings → Focus → Stint to auto-set a project per focus mode. +``` + +Find the Widget bullet — verify it still accurately describes the gallery experience (gallery now actually works). + +- [ ] **Step 4: Commit** + +```bash +git add README.md CLAUDE.md crates/stint-cli/skills/stint/SKILL.md +git commit -m "docs(6d): roadmap rows + SKILL.md reflect live App Intents + Widget" +``` + +--- + +## Task D11: Tag phase-6d-complete (LOCAL ONLY) + +**Files:** none. + +- [ ] **Step 1: Sanity check** + +```bash +git status +git log --oneline | head -10 +``` + +Expected: clean working tree; the recent commits tell the 6d story. + +- [ ] **Step 2: Tag** + +```bash +git tag -a phase-6d-complete -m "Phase 6d complete — Xcode-based extensions (App Intents + Widget) live" +``` + +- [ ] **Step 3: STOP** + +Surface to the user: "Phase 6d is complete on local branch `phase-6d`, tagged `phase-6d-complete`. Ready to push and open the PR to main?" + +**DO NOT** `git push`, open a PR, force-push, or trigger any release. The user explicitly governs push/release. + +--- + +## Self-review checklist + +After writing the complete plan, look at the spec with fresh eyes and check the plan against it. This is a checklist for the planner, NOT a step for subagents. + +**Spec coverage:** + +- §2 Goals — Siri (Phase B, verified in B7 + D9), Shortcuts.app (B7 + D9), Focus filter (D9 step 5), Edit Widgets gallery (A11 + D9 step 1), Spotlight (C9 + D9 step 6). ✅ +- §3 Architecture — file moves (A3/A6/B1/C5/D7), directory layout (matches §3 diagram). ✅ +- §4 Build flow — xcodegen + xcodebuild + appex repackage (A9 / B5 / D2). ✅ +- §5 IPC — SharedContainerMarker (C3), DarwinNotification (C4), App Group entitlement (C1/C2), Rust-side push_pending (C6 / C7), extension-side drain (C8). ✅ +- §6 Migration order A→D — preserved literally. ✅ +- §7 Tests + manual smoke — D8 + D9 cover all 8 items. ✅ +- §8 CI — A12 (add) + D5/D6 (update + drop legacy). ✅ +- §9 Local-dev — A1. ✅ +- §10 Entitlements — A7, B2, C1, C2. ✅ +- §11 Trade-offs — addressed in spec, not actionable in plan. ✅ +- §12 Success criteria — D8 (coverage), D9 (manual smoke), D10 (roadmap). ✅ +- §13 Out-of-scope — no out-of-scope work appears in the plan. ✅ + +**Placeholder scan:** every step has concrete code, exact paths, exact commands. No TBD / TODO / "implement appropriate error handling" / "similar to Task N". + +**Type consistency:** `SpotlightOp` enum (Rust spotlight_ipc + Swift SharedContainerMarker) uses the same camelCase variant names (`insert`/`update`/`delete`/`projectsReplaced`/`tasksReplaced`). Marker file shape (`localUuid`, `kind`) consistent across producer (stint-core `append_pending`) and consumer (Swift `SharedContainerMarker.loadOps`). + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-05-28-stint-phase-6d-xcode-extensions.md`.** + +Two execution options: + +**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, two-stage review, fast iteration. Best for this plan given the variety (Swift / Rust / build scripts / CI / manual smoke). + +**2. Inline Execution** — execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints. + +**Which approach?** + +**Reminder before execution starts:** +- Branch from clean `main` AFTER 6c lands — NOT from `feature/task-assignment`. +- Never push, force-push, merge to main, `--no-verify`, or `--no-gpg-sign` unless the user explicitly asks for it. +- Phase D9 (manual smoke) and D11 (tag) require user-driven actions; halt before either. diff --git a/docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md b/docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md new file mode 100644 index 0000000..c5ff777 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md @@ -0,0 +1,477 @@ +# stint — Phase 6: Deeper macOS integration (spec) + +Extend stint beyond CLI/GUI/MCP/HTTP into the macOS shell itself — App Intents (Shortcuts + Siri + Focus filters), Core Spotlight (CSSearchableIndex + NSUserActivity), Raycast/Alfred surfaces, WidgetKit, and idle detection. Built on top of the Phase 6a verbs façade. + +- **Status:** Confirmed 2026-05-25. **Shipped 2026-05-26 as foundation-only — see §1.5 for what's actually live vs deferred.** +- **Predecessors:** Phase 6a (verbs façade, MCP, HTTP API, `stint://` URL scheme, `stint skill install`, man page — shipped) +- **Decomposition:** This phase splits into two sub-phases. + - **6b** — Core Spotlight + App Intents (Shortcuts / Siri / Focus filters). Detailed below. + - **6c** — Raycast extension + Alfred workflow + WidgetKit + idle detection. Outlined here, full spec to be written when 6b ships. + +## 1.5 What actually shipped (2026-05-26) + +**This is a partial ship — Spotlight works end-to-end, Siri/Shortcuts/Focus-filter discovery doesn't.** After extended debugging — switching from embedded framework to static-link and back, ad-hoc and Developer-ID signing, full notarization, app-level Metadata.appintents stencil — Apple's App Intents indexer (`siriactionsd`/`assistantd`/Shortcuts.app) remains silent on our bundle. We accept that the path from "intents in a SwiftPM target linked into a non-Xcode-driven app" to "macOS Siri/Shortcuts discovery" has an undocumented gap we couldn't isolate from CLI. Spotlight's NSUserActivity + CSSearchableIndex surfaces, on the other hand, **do work** once the right architecture was found. + +| Surface | Status | Notes | +|---|---|---| +| Rust FFI bridge (`stint_core::ffi`) | ✅ shipped | 8 verb wrappers, settings, log forwarder, focus id, notify_indexer hook — all tested via cargo tests | +| `stint://` URL routes (entry / project / task) | ✅ shipped | Tauri deep-link handler routes them to the SolidJS UI; entry route looks up the date and emits `/today?entry=&date=` for the Today view to highlight | +| Entry-row scroll + amber-pulse highlight on deep-link | ✅ shipped | Today.tsx reads `?entry=` via useSearchParams; EntryRow scrolls into view + adds a 2.5s ring-amber-400 pulse | +| `focus.default_project` fallback in `verbs::start` | ✅ shipped | Applies the focus filter's persisted default; reconciled against current focus id | +| Swift StintIntents framework (dynamic) | ✅ shipped | Embedded at `Contents/Frameworks/StintIntents.framework`; loads at app launch via `-needed_framework`; `stint_intents_init` runs (verified in production log) | +| `NSUserActivity` for the running entry | ✅ shipped | Activity carries `webpageURL = stint://entry/` so taps from Spotlight's live-activity tile route correctly | +| `CSSearchableIndex` entries / projects / tasks | ✅ shipped | Indexed items carry `attributeSet.url` set to the matching `stint://` route; taps route through the deep-link handler | +| **App Intents discovery by Siri / Shortcuts.app** | ❌ **deferred** | Apple's indexer remains silent on our bundle. Likely requires using Apple's App Intents Extension (.appex) target template, which Tauri can't currently produce | +| **`ProjectFocusFilter` in System Settings → Focus** | ❌ **deferred** | Same root cause as above | + +**What this is good for:** Spotlight integration works — Cmd+Space → entry description → tap → opens Stint focused on that entry. URL scheme additions, focus-fallback infrastructure, and the FFI bridge are all live. + +**What this is not good for:** anything that requires Siri voice activation or Shortcuts.app gallery discovery. + +**Re-enabling the still-deferred Siri/Shortcuts surfaces** is a follow-up that should: +1. Add a real `.xcodeproj` (or `.appex` extension target) that produces a proper App Intents Extension bundle under `Contents/PlugIns/`. +2. Move the existing Swift intent types into that target unchanged. +3. The Rust FFI surface + URL scheme already in place — no Rust changes needed. + +The 6c scope (Raycast / Alfred / WidgetKit / idle) is unaffected by the deferral: those surfaces don't go through Apple's intent indexer. + +## 1. Goal + +Make stint feel like a first-class macOS citizen by exposing the existing verbs through the system surfaces a macOS power user expects: + +- Cmd+Space → "client meeting" → tap → open the entry in stint +- "Hey Siri, start tracking 'writing tests' in Stint" → timer running +- "When Work focus is on, default new timers to my Work project" → no manual project switch +- A Stint widget on the desktop showing the running timer and total hours today (6c) +- Raycast / Alfred ⌥-Space → fuzzy-match stint actions (6c) +- "You've been idle for 10 minutes — was that part of your timer?" (6c) + +All of this consumes the Phase 6a verbs façade. Zero new business logic in 6b — only transport adapters into Apple's frameworks. + +## 2. Scope + +### 2.1 In scope for 6b + +- **App Intents** — `AppIntent` types covering all 8 verbs (Custom Shortcuts), plus an `AppShortcutsProvider` curating 5 of them as App Shortcuts (voice / Spotlight quick-actions). +- **Core Spotlight** — `CSSearchableIndex` for entries + projects + tasks (three distinct domain identifiers). `NSUserActivity` for the currently running entry. +- **Focus filters** — one filter target: default project for new timers per Focus mode. +- **Swift packaging** — a Swift Package at `crates/stint-app/swift/StintIntents/` produces `StintIntents.framework`, embedded into the Tauri-built `Stint.app/Contents/Frameworks/`. +- **FFI bridge** — bidirectional. Rust exposes `extern "C"` verb wrappers; Swift exposes `@_cdecl` indexer-notify symbols looked up via `dlsym`. +- **URL scheme additions** — `stint://project/` and `stint://task/` routes for Spotlight taps. + +### 2.2 In scope for 6c (outlined only) + +- **Raycast extension** — TypeScript extension talking to the verbs via `stint --json` subprocess (CLI ships with the cask). +- **Alfred workflow** — equivalent to Raycast, distributed as a `.alfredworkflow` bundle. +- **WidgetKit widget** — small/medium widget showing running timer, today's totals, project breakdown. Built as a Widget Extension target in the same SPM workspace. +- **Idle detection** — macOS `CGEventSourceSecondsSinceLastEventType` polling in the GUI process. On detected idle > threshold, prompt user to discard or keep the idle minutes. + +6c uses the same verbs façade and the same FFI bridge 6b establishes, so the architectural work in 6b carries through. + +### 2.3 Out of scope + +- **MAS (Mac App Store) submission** — Phase 4.5. +- **iOS / iPadOS targets** — separate effort; would need a re-architecture for non-macOS data sync. +- **Localization beyond `en`** — the `.xcstrings` file structure is set up to accept future translations; no other locales shipped in 6b. +- **Apple Intelligence integrations** (writing tools on entry descriptions, smart suggestions) — too new, API surface unstable. +- **Multiple Focus filter targets** — only default project in 6b. Billable defaults and Solidtime org switching are explicitly deferred. + +## 3. Architecture + +### 3.1 Process model + +Single `Stint` binary. `StintIntents.framework` is dynamically loaded from `Contents/Frameworks/` at first FFI symbol reference. The Swift runtime loads, App Intents reflection discovers the types via the framework's `Info.plist` and `Metadata.appintents` stencil generated by SPM at build time. + +The same `stint-core` crate is also consumed by `stint-cli`, which never loads the framework. The Rust→Swift indexer-notify call is resolved via `dlsym` and no-ops when the symbol is absent — `stint-cli` stays Spotlight-unaware. + +### 3.2 New artifacts + +``` +crates/stint-app/ + swift/ + StintIntents/ + Package.swift # SPM manifest + Sources/StintIntents/ + Bridge.swift # FFI declarations + @_cdecl exports + Intents/ + StartTimerIntent.swift + StopTimerIntent.swift + GetCurrentIntent.swift + ListEntriesIntent.swift + ListProjectsIntent.swift + ListTasksIntent.swift + UpdateEntryIntent.swift + DeleteEntryIntent.swift + SwitchProjectIntent.swift + LogPastIntent.swift + Shortcuts/ + StintAppShortcutsProvider.swift # the 5 curated App Shortcuts + PhraseStrings.xcstrings # phrase localization (en seeded) + Entities/ + EntryEntity.swift # AppEntity + IndexedEntity + ProjectEntity.swift + TaskEntity.swift + EntryQuery.swift # EntityQuery + EntityStringQuery + ProjectQuery.swift + TaskQuery.swift + Spotlight/ + SpotlightIndexer.swift # CSSearchableIndex bulk + delta + ActivityTracker.swift # NSUserActivity for running entry + Focus/ + ProjectFocusFilter.swift # SetFocusFilterIntent + Errors/ + BridgeError.swift # IntentError + envelope decode + Tests/StintIntentsTests/ # unit tests (mocked bridge) + Tests/StintIntentsIntegrationTests/ # links real stint_core static lib + build.rs # extended: `swift build` + copy framework + +crates/stint-core/ + src/ + ffi.rs # extern "C" verb wrappers + envelope + url_scheme.rs # extended: OpenProject, OpenTask + include/ + stint_core.h # C header for Swift bridging +``` + +### 3.3 Bundle layout (post-build) + +``` +Stint.app/ + Contents/ + MacOS/Stint # Rust binary with FFI symbols + Frameworks/ + StintIntents.framework/ + StintIntents # Swift dylib + Info.plist + Resources/ + Metadata.appintents # generated by SPM at build time + PhraseStrings.lproj/ + Resources/ + man/man1/stint.1 # existing +``` + +Tauri's `bundle.macOS.frameworks` in `tauri.conf.json` lists the SPM-built framework path. Tauri's bundle step copies + codesigns it as part of the standard release flow. + +### 3.4 IPC channels + +| Direction | Channel | Used for | +|---|---|---| +| Swift → Rust | `extern "C"` FFI | App Intent `perform()` — needs return values | +| Rust → Swift | `@_cdecl` via `dlsym` | Spotlight index delta on verb mutation | +| Swift → System | `stint://...` URL | "Open the GUI focused on X" Custom Shortcuts | +| System → Swift | NSUserActivity / `CSSearchableItem` tap | Spotlight result tap routes through `stint://entry/` to existing deep-link handler | + +### 3.5 Build flow + +``` +cargo build -p stint-app + └─→ stint-app/build.rs + ├─→ swift build --product StintIntents -c + │ └─→ produces StintIntents.framework (SPM with xcodebuild post-step) + └─→ copies framework to OUT_DIR + +cargo tauri build + └─→ tauri reads bundle.macOS.frameworks → embeds + codesigns +``` + +The 30-minute SPM spike (first execution task) verifies `swift build` produces a usable framework with the App Intents metadata stencil correctly generated. If that fails, fall back to an Xcode `.xcodeproj` driven by `xcodebuild` — rest of design unchanged. + +## 4. App Intents surface + +### 4.1 App Shortcuts (curated, public phrase contract) + +| # | Intent | Phrases | Parameters | Returns | +|---|---|---|---|---| +| 1 | `StartTimerIntent` | "Start timer in Stint", "Start tracking in Stint", "Start ${project} in Stint" | optional `project: ProjectEntity`, prompts for `description` | dialog: "Tracking '${desc}' on ${project}." | +| 2 | `StopTimerIntent` | "Stop Stint timer", "Stop tracking in Stint" | none | dialog: "Stopped. ${duration} on ${project}." | +| 3 | `GetCurrentIntent` | "What am I tracking in Stint", "Show current Stint timer" | none | `EntryEntity` + dialog | +| 4 | `SwitchProjectIntent` | "Switch to ${project} in Stint" | required `project: ProjectEntity` | dialog: "Switched to ${project}." | +| 5 | `LogPastIntent` | "Log past ${duration} in Stint", "Log last meeting in Stint" | required `duration: Measurement`, optional `project`, optional `description` | dialog: "Logged ${duration} on ${project}." | + +**Phrase strings are a public contract.** Once shipped, renaming them breaks users' voice shortcuts. Strings live in `PhraseStrings.xcstrings`. + +### 4.2 Custom Shortcuts (full verb surface) + +All 8 verbs exposed as `AppIntent` types, discoverable in Shortcuts.app. The five App Shortcut intents above double as Custom Shortcuts. Three additional Custom-only intents: + +- `ListEntriesIntent` — `since?`, `until?`, `project?`, `limit?` → `[EntryEntity]`. Chainable in Shortcuts pipelines. +- `ListProjectsIntent` → `[ProjectEntity]`. +- `ListTasksIntent` — `project: ProjectEntity` → `[TaskEntity]`. +- `UpdateEntryIntent` — `entry: EntryEntity`, optional `description`, `project`, `task`, `billable`, `startAt`, `endAt` (per `EntryPatch` semantics) → `EntryEntity`. +- `DeleteEntryIntent` — `entry: EntryEntity` → void. + +Each takes a `Bridge` (protocol) via `init()` with default `FFIBridge.shared` for production and `StubBridge` injection in unit tests. + +### 4.3 Entities + +| Entity | `id` | `title` | `subtitle` | `image` | +|---|---|---|---|---| +| `EntryEntity` | `local_uuid` | description | `${date} · ${duration} · ${project_name}` | project color swatch | +| `ProjectEntity` | `solidtime_id` | project name | `Project${client?: " · " + client_name}` | color swatch | +| `TaskEntity` | task UUID | task name | `Task in ${project_name}` | parent project color | + +`EntryQuery: EntityStringQuery` allows fuzzy-string matching ("entry about lunch") for parameter resolution. `ProjectQuery: EntityQuery` and `TaskQuery: EntityQuery` provide enumeration via the bridge. + +Each entity declares `@Property` annotations on filterable fields (`billable: Bool`, `duration: Measurement`, `startAt: Date`, `endAt: Date?`, `project: ProjectEntity?`) so Shortcuts can compose filters and computations. + +### 4.4 Composed-intent semantics + +- **`SwitchProjectIntent`** = `stop` (if running) → `start` with same description + new project. Errors with "No timer to switch from." if no current entry. +- **`LogPastIntent`** = `start { start_at: now - duration, … }` → `stop`. Reuses existing semantics, no new "retroactive entry" verb needed. + +## 5. Spotlight indexing + +### 5.1 Domain identifiers + +| Domain | Source | Title | Subtitle | Keywords | Tap → | +|---|---|---|---|---|---| +| `tech.reyem.stint.entry` | `local_uuid` | description | `${date} · ${duration} · ${project_name}` | project name, task name, "stint" | `stint://entry/` | +| `tech.reyem.stint.project` | `solidtime_id` | project name | `Project${client?: " · " + client_name}` | project name, client name | `stint://project/` (new) | +| `tech.reyem.stint.task` | task UUID | task name | `Task in ${project_name}` | task name, parent project name | `stint://task/` (new) | + +`CSSearchableItemAttributeSet.thumbnailData` is a 16×16 PNG generated on the fly from the project color. Generated once per project per session and cached in a `[String: Data]` dictionary keyed by hex color. + +### 5.2 `NSUserActivity` for the running entry + +```swift +activityType = "tech.reyem.stint.tracking" +title = "Tracking: \(description)" +userInfo = ["uuid": local_uuid] +isEligibleForSearch = true +isEligibleForHandoff = true +isEligibleForPrediction = true +``` + +Activated on `start`, mutated on `update_entry` if the running entry's description changes, invalidated on `stop`. Surfaces at the top of Spotlight as a live-activity card. + +### 5.3 Indexer lifecycle + +``` +App launch (Tauri setup()) + └─→ stint_intents_init() [Rust → Swift FFI] + ├─→ Task.detached(priority: .background) { + │ SpotlightIndexer.bulkRefresh() + │ ├─→ FFI list_entries → upsert all entry items + │ ├─→ FFI list_projects → upsert all project items + │ └─→ FFI list_tasks → upsert all task items + │ } + └─→ ActivityTracker.activate() + └─→ FFI current → register NSUserActivity if running + +Verb mutation (after successful store write, before sync enqueue) + └─→ stint_core::ffi::notify_indexer(kind, payload_json) + └─→ cached dlsym("swift_indexer_notify") → call (or no-op) + └─→ SpotlightIndexer.delta(kind, payload) + ├─→ EntryStarted/Updated: upsert + ActivityTracker.activate + ├─→ EntryStopped: upsert + ActivityTracker.invalidate + ├─→ EntryDeleted: deleteSearchableItems([uuid]) + └─→ ProjectsReplaced / TasksReplaced (from pull_worker): re-bulk that slice +``` + +The bulk refresh is dispatched off the setup() critical path. First-launch Spotlight results may be stale for up to ~1-2 seconds after launch — accepted. + +### 5.4 Index consistency model + +macOS is the source of truth. No `last_indexed_at` columns in SQLite. Bulk reindex on every launch uses `indexSearchableItems` with upsert-on-unique-identifier semantics — no delete-first needed. Explicit deletes via `deleteSearchableItems(withIdentifiers:)`. + +**Accepted edge case:** if entries are deleted while Stint.app is not running, the index could orphan. Currently impossible — every delete path (CLI, MCP, HTTP) routes through `verbs::delete_entry` which calls `notify_indexer` synchronously, and the GUI must be running for HTTP. If this changes in a future phase (e.g., a background sync worker that deletes adopted entries), a GC pass that reconciles against SQLite on launch becomes necessary. + +### 5.5 New URL routes + +```rust +// crates/stint-core/src/url_scheme.rs +pub enum Action { + // existing + Start { ... }, Stop, OpenEntry { local_uuid }, Current, + // new in 6b + OpenProject { project_id: String }, + OpenTask { task_id: String }, +} +``` + +`OpenProject` navigates to `/today?project=`. `OpenTask` resolves task → project_id via `verbs::list_tasks` then navigates to `/today?project=&task=`. + +## 6. Focus filters + +### 6.1 Filter target + +One: **default project for new timers per Focus mode.** + +### 6.2 Swift type + +```swift +struct ProjectFocusFilter: SetFocusFilterIntent { + static let title: LocalizedStringResource = "Default Project" + static let description: IntentDescription = "Set a default project for new Stint timers while this focus is on." + + @Parameter(title: "Project") var project: ProjectEntity + + func perform() async throws -> some IntentResult { + // OS calls perform() on every focus activation that has this filter + // configured. It does NOT call perform() on deactivation. We store the + // currently-active focus identifier alongside the project so the + // start-verb path can reconcile. + let focusId = INFocusStatusCenter.default.focusStatus.activity?.identifier ?? "" + let payload = "\(focusId)\t\(project.id)" + let rc = stint_settings_set("focus.default_project", payload) + guard rc == 0 else { throw BridgeError.internal("settings_set failed") } + return .result() + } +} +``` + +**Deactivation reconciliation.** The OS does not invoke `perform()` on focus deactivation. To detect "this filter is no longer active" we store `(focus_id, project_id)` as a tab-separated string. The Rust `verbs::start` fallback path reads both, queries the current macOS focus identifier via a small FFI (`stint_current_focus_id() -> *mut c_char`), and applies the stored `project_id` only if the focus IDs match. If they differ, the stored value is stale and ignored. This is simpler than registering `NSWorkspace` focus observers from Swift and dealing with their lifetime. + +### 6.3 Fallback semantics in `verbs::start` + +```rust +pub fn start(store: &Store, params: StartParams) -> Result { + let project_id = params.project_id.or_else(|| { + // Read the (focus_id, project_id) pair stored by ProjectFocusFilter. + // Reconcile against the currently active focus — ignore if stale. + let raw = store.settings_get("focus.default_project").ok().flatten()?; + let (stored_focus, project_id) = raw.split_once('\t')?; + let current_focus = focus::current_id(); // shells out to dlsym'd Swift fn + (current_focus.as_deref() == Some(stored_focus)).then(|| project_id.to_string()) + }); + // existing logic with (possibly defaulted) project_id +} +``` + +Applies uniformly to CLI, MCP, HTTP, GUI, and App Intents — anywhere a start is initiated without an explicit project. + +### 6.4 Edge cases + +- **Running timer when focus changes** — untouched. Focus default applies only to new starts. Stopping+restarting would corrupt the user's current tracking. +- **CLI start immediately after focus activation while app is cold-launching** — ~200ms race window where settings write may not have landed. Worst case: entry created without the focus default; fixable via `stint edit`. Documented in `SKILL.md`. + +## 7. Error handling + +### 7.1 Envelope contract + +Every FFI verb returns a JSON envelope: + +``` +{ "ok": } +| { "err": { "code": , "message": "" } } +``` + +Codes are a stable contract (never renumber): + +| Code | Variant | Surface | +|---|---|---| +| 0 | success | (the verb's success dialog) | +| 1 | `Invariant` (e.g., timer already running) | message verbatim | +| 2 | `NotFound` (project / entry lookup miss) | message verbatim | +| 3 | `Conflict` (sync overlap) | "That conflicts with an existing entry." | +| 4 | `Serialization` | "Couldn't read the request." | +| 99 | `Internal` | "Stint hit an internal error. Check the app." | +| -1 | `Panic` (caught via `catch_unwind`) | "Stint encountered an unexpected error." | +| -2 | `Misuse` (null out_json) | unreachable from Swift bridge | + +### 7.2 Panic safety + +Every FFI wrapper body runs inside `std::panic::catch_unwind`. A caught panic becomes a `-1` envelope with the panic message. Without this, a panic across the C ABI is undefined behavior. Test: `crates/stint-core/tests/ffi_panic_safety.rs` forces a panic and asserts the envelope. + +### 7.3 Sync errors stay local-first + +Solidtime upload failures are handled by the existing async `sync_worker`. They **never** surface to App Intents — the intent's `perform()` only knows whether the local write succeeded. The user gets "Started timer." even if Solidtime is down; the GUI's existing `SyncErrorBanner` surfaces the failure on next launch. + +### 7.4 Spotlight / NSUserActivity are best-effort + +Spotlight write failures are logged via `stint_log_warn` (a small FFI surface that funnels into the existing `tracing` subscriber) and never propagate to the caller. Next `bulkRefresh()` reconciles. + +### 7.5 Framework load-time failures + +| Failure | Detection | Behavior | +|---|---|---| +| Framework missing from bundle | First FFI call dlopens implicitly; symbol unresolved at link time → launch fails | CI gate catches this before release | +| Codesign mismatch | Gatekeeper blocks launch | CI: `codesign --verify --deep --strict Stint.app` | +| App Intents not registered (metadata stencil broken) | Intents don't appear in Shortcuts.app | CI: parse `Stint.app/Contents/Frameworks/StintIntents.framework/Resources/Metadata.appintents` and assert ≥11 intent types (8 verb intents + 2 composed + `ProjectFocusFilter`) | +| `swift_indexer_notify` symbol missing in CLI builds | `dlsym` returns null | No-op; CLI stays Spotlight-unaware | + +### 7.6 Concurrency + +The Store is `Arc`-backed. Concurrent FFI calls from Swift+CLI+MCP+HTTP serialize through the same mutex. No new concurrency primitives in 6b. + +## 8. Testing strategy + +Five layers: + +| Layer | Location | Run via | Counted toward coverage | +|---|---|---|---| +| Rust FFI wrappers | `crates/stint-core/tests/ffi*.rs` | `cargo test` | Yes (stint-core) | +| Swift unit tests (mocked bridge) | `crates/stint-app/swift/StintIntents/Tests/StintIntentsTests/` | `swift test` | Tracked separately (≥80% local) | +| Swift integration (real Rust FFI) | `Tests/StintIntentsIntegrationTests/` | `swift test` | Tracked separately | +| Bundle integration (CI-only smoke) | `.github/workflows/ci.yml` | `codesign --verify` + `pluginkit -mvD` | N/A | +| Manual smoke checklist | PR description | Reviewer-driven | N/A | + +Swift coverage is **not** merged into `scripts/coverage.sh` in 6b — deferred to a follow-up chore. Local discipline: ≥80% line coverage on `Sources/StintIntents/` via `swift test --enable-code-coverage`. + +What's not tested: +- Real Spotlight search results (macOS indexing pipeline timing is non-deterministic in CI). +- Siri voice recognition (impossible to automate). +- `NSUserActivity` handoff (requires two-Mac setup). + +These are accepted manual-smoke items. + +## 9. Trade-offs and deferred work + +| Decision | Trade-off | Deferred alternative | +|---|---|---| +| SPM-built framework (not Xcode `.xcodeproj`) | Cleaner manifest; risk in `.xcstrings` phrase generation | Xcode project fallback if 30-min spike fails | +| FFI + URL scheme hybrid | Two channels to maintain | Single-channel HTTP-only — adds latency and a port-discovery step | +| `dlsym` lookup for indexer-notify | CLI binary stays unaware of Spotlight | Static linking — would force CLI to ship framework or fail to link | +| App Intents in Custom + 5 App Shortcuts | Phrase strings are a public contract | Custom-only — invisible to non-Shortcuts users | +| Comprehensive Spotlight (entry+project+task) | +1 day of Swift code | Entry-only — would still need EntityQuery for parameter resolution | +| One Focus filter (default project) | Limited scope | Add billable / org-switch filters in 6b.1 or 6c (30 LoC each) | +| No GC pass on the Spotlight index | Assumes deletes always run through `notify_indexer` | Add reconcile-on-launch sweep if invariant breaks | +| Swift coverage not in unified report | Local-only discipline in 6b | Merge into `scripts/coverage.sh` as a follow-up chore | +| Launch-time bulk reindex on background queue | Stale results for ~1-2s after launch | Synchronous reindex — would block app launch UI | + +## 10. Implementation order (preview) + +The plan doc (`docs/superpowers/plans/2026-05-25-stint-phase-6b-spotlight-app-intents.md`) will sequence the work. High-level order: + +1. **SPM spike** (30 min) — produce a minimal `StintIntents.framework` with one stub App Shortcut; verify `pluginkit -mvD` sees it. +2. **Rust FFI surface** — `crates/stint-core/src/ffi.rs` with envelope + 8 verb wrappers + settings get/set + log forwarder + panic-safety test. +3. **C header** — hand-written `stint_core.h` consumed by Swift bridging header. +4. **Swift package scaffold** — `Package.swift`, `Bridge.swift` with extern declarations + `Bridge` protocol + `FFIBridge` impl + `StubBridge` test impl. +5. **Entities** — `EntryEntity`, `ProjectEntity`, `TaskEntity` + their `EntityQuery` types. +6. **Spotlight** — `SpotlightIndexer`, `ActivityTracker`. Unit tests against `CSSearchableItemAttributeSet` shape. +7. **App Intents** — 10 intent types (5 App Shortcuts × double-duty + 3 list intents + update + delete). One file per intent, mocked-bridge unit test each. +8. **App Shortcuts provider** — `StintAppShortcutsProvider` + `PhraseStrings.xcstrings`. +9. **Focus filter** — `ProjectFocusFilter` + `stint_settings_set/clear` FFI + fallback in `verbs::start`. +10. **URL scheme extension** — `OpenProject`, `OpenTask` actions + Tauri deep-link routing + UI navigation. +11. **Tauri integration** — `stint-app/build.rs` runs `swift build` + copies framework; `tauri.conf.json` `bundle.macOS.frameworks` reference; setup hook calls `stint_intents_init()`. +12. **Pull worker hook** — `pull_worker` calls `notify_indexer(ProjectsReplaced/TasksReplaced)` after each successful pull. +13. **CI gates** — `codesign --verify` step + `pluginkit -mvD` count assertion. Swift test step. +14. **Manual smoke** — checklist exercise on a release-mode `cargo tauri build` install. +15. **Docs** — extend `SKILL.md` with App Intents surface ladder, focus-filter race documentation, and stint:// URL route additions. + +## 11. 6c outline (full spec deferred) + +| Surface | Stack | Approx scope | +|---|---|---| +| Raycast extension | TypeScript, talks to `stint --json` subprocess | ~1.5 days | +| Alfred workflow | Bash/PHP scripts + Alfred workflow bundle | ~0.5 days | +| WidgetKit widget | Swift Widget Extension target in same SPM workspace | ~2 days | +| Idle detection | Rust `CGEventSourceSecondsSinceLastEventType` polling in `stint-app` + prompt UI | ~1 day | + +6c consumes 6b's FFI bridge for the widget (which runs in its own process — would need a different IPC story; likely loopback HTTP since the widget process is short-lived and can't link the framework). Full spec written when 6b lands. + +## 12. References + +- Phase 6a façade: `crates/stint-core/src/verbs/mod.rs` +- Existing URL scheme parser: `crates/stint-core/src/url_scheme.rs` +- HTTP API handlers (same shapes): `crates/stint-app/src/http/handlers.rs` +- MCP server (same shapes): `crates/stint-cli/src/cmd/mcp.rs` +- SKILL.md (will be extended): `crates/stint-cli/skills/stint/SKILL.md` +- Tauri bundle config: `crates/stint-app/tauri.conf.json` +- Tauri entitlements: `crates/stint-app/entitlements.plist` + +Apple references: +- App Intents framework — [`developer.apple.com/documentation/appintents`](https://developer.apple.com/documentation/appintents) +- Core Spotlight — [`developer.apple.com/documentation/corespotlight`](https://developer.apple.com/documentation/corespotlight) +- `SetFocusFilterIntent` — [`developer.apple.com/documentation/appintents/setfocusfilterintent`](https://developer.apple.com/documentation/appintents/setfocusfilterintent) +- App Shortcuts phrase guidelines — [`developer.apple.com/documentation/appintents/app-shortcuts`](https://developer.apple.com/documentation/appintents/app-shortcuts) diff --git a/docs/superpowers/specs/2026-05-27-stint-phase-6c-power-user-surfaces-design.md b/docs/superpowers/specs/2026-05-27-stint-phase-6c-power-user-surfaces-design.md new file mode 100644 index 0000000..82e5c48 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-stint-phase-6c-power-user-surfaces-design.md @@ -0,0 +1,508 @@ +# stint — Phase 6c: Power-user surfaces (spec) + +Four independent macOS power-user surfaces sitting on top of the Phase 6a CLI / HTTP / URL-scheme primitives: a Raycast extension, an Alfred workflow, a WidgetKit widget, and idle detection inside `stint-app`. + +- **Status:** Confirmed 2026-05-27. +- **Predecessors:** + - Phase 6a (verbs façade, MCP, HTTP API, `stint://` URL scheme, `stint skill install`, man page) — shipped. + - Phase 6b (Spotlight indexing + URL-scheme tap routing) — shipped foundation; Siri/Shortcuts/Focus-filter discovery deferred. See [Phase 6 spec §1.5](./2026-05-25-stint-phase-6-deeper-integration-design.md). +- **Decomposition:** Four largely-independent surfaces. Each ships in its own task slice; later surfaces don't block earlier ones. + +## 1. Goal + +Make stint feel "where my keystrokes already live" for three distinct user types: + +- **Raycast power user** — every action ⌥-Space away, with autocomplete on projects + tasks. +- **Alfred user** — same actions, fewer keystrokes, via classic keyword + script-filter workflow. +- **Always-on dashboard user** — desktop / lock-screen widget showing the running timer or today's total without launching anything. +- **Anyone** — idle detection rescues abandoned timers ("you walked away 14 minutes ago — keep or discard?"). + +All four consume the existing CLI / HTTP API / URL scheme. **Zero new stint-core verbs.** Whatever's in stint-core today is the API; 6c adds adapters. + +## 2. Scope + +### 2.1 In scope + +- **Raycast extension** at `raycast-stint/` — TypeScript, 5 commands, subprocess-to-CLI. +- **Alfred workflow** at `alfred-stint/` — bash scripts, 4 keywords, subprocess-to-CLI. +- **CLI extension** — new `stint projects list-tasks ` subcommand wrapping `verbs::list_tasks` (the verb already exists; just needs the CLI clap struct + a 5-line handler). Required by Raycast's Start Timer task picker; small enough to not warrant its own phase. +- **WidgetKit widget** at `crates/stint-app/swift/StintWidget/` — Swift Package producing `.appex`, embedded under `Stint.app/Contents/PlugIns/StintWidget.appex/`. Two sizes (small + medium). Three kinds (running timer, today total, this-week project). Per-instance configurable via `WidgetConfigurationIntent`. +- **Idle detection** inside `stint-app` — `CGEventSourceSecondsSinceLastEventType` polling. Settings-configurable threshold (default 10 min). Popover banner with Keep / Discard / Discard+restart actions. Three Tauri commands backing those buttons. + +### 2.2 Out of scope + +- **Raycast Store publication** — covered separately by a PR to `raycast/extensions` after the extension stabilizes locally. +- **Alfred Gallery publication** — same; ship via GitHub Releases first. +- **Widget gallery iCloud sync** — Apple handles this transparently; nothing for us. +- **Lock-screen widgets** — macOS support is sparse; no demand. +- **Idle detection on Linux/Windows** — stint is macOS-only. +- **Re-enabling Siri / Shortcuts.app discovery** — that's Phase 6b.1 (still deferred). The WidgetKit `.appex` pipeline this phase establishes does open a path for that work, but it's not in 6c. + +## 3. Architecture + +``` +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Raycast ext │ │ Alfred workflow │ │ WidgetKit │ │ Idle detection │ +│ (TypeScript) │ │ (bash scripts) │ │ (Swift .appex) │ │ (Rust) │ +└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ + │ │ │ │ + │ stint --json … │ │ HTTP /v1/* │ in-process + │ subprocess │ │ on loopback │ tokio task + └─────────┬───────────┘ │ (port discovery via │ + ▼ │ api.port file) │ + ┌─────────────────────────────────────── ┴ ────────────────────┴──┐ + │ Phase 6a / 6b primitives │ + │ CLI binary · Loopback HTTP API · stint:// URL scheme │ + └──────────────────────────────────────────────────────────────────┘ +``` + +### 3.1 New artifacts + +``` +crates/stint-app/ + swift/StintWidget/ # NEW Swift Package + Package.swift + Sources/StintWidget/ + StintWidgetBundle.swift # @main, WidgetBundle entry + RunningTimerWidget.swift # Widget declaration + WidgetConfigIntent.swift # WidgetConfigurationIntent + Provider.swift # TimelineProvider + Models/ # DTO mirrors of HTTP shapes + EntryDTO.swift ProjectDTO.swift PortDiscovery.swift + Views/ + RunningTimerView.swift TodayTotalView.swift WeekProjectView.swift + src/ + idle_detector.rs # NEW + commands/idle.rs # NEW Tauri commands + Cargo.toml # +core-graphics dep for CGEvent + +ui/src/ + components/IdleBanner.tsx # NEW + +raycast-stint/ # NEW top-level dir + package.json + src/start-timer.tsx stop-timer.tsx current.tsx + recent-entries.tsx switch-project.tsx + README.md + assets/icon.png + +alfred-stint/ # NEW top-level dir + info.plist + start.sh stop.sh current.sh recent.sh + icon.png + README.md +``` + +### 3.2 Per-surface IPC + +| Surface | Talks to stint via | Why | +|---|---|---| +| Raycast | `stint --json` subprocess | Works whether GUI runs; easiest TS integration | +| Alfred | `stint --json` subprocess | Workflow scripts are bash-native | +| Widget | HTTP `/v1/*` (loopback) | Widget process is sandboxed + short-lived; can't shell out; can't link stint-core | +| Idle | in-process Rust | Lives inside stint-app, same process as the rest of the GUI | + +### 3.3 New stint-app subsystems + +- **`api.port` file** — on HTTP API bind, stint-app writes the bound port to `~/Library/Application Support/stint/api.port` (plain text, one line: `49792\n`). Removed on graceful shutdown. Widget reads this on every timeline refresh. +- **Widget-presence-aware HTTP auto-enable** — at setup time, if `WidgetCenter.shared.getCurrentConfigurations` (via a small Swift FFI helper) reports ≥1 stint widget installed, and `api.enabled = false`, auto-flip it to `true` and persist. One-time onboarding flicker; no user action needed. +- **Idle worker** — tokio task in `stint-app/src/idle_detector.rs`. Polls every 60s while a timer is running. Emits `idle:detected` Tauri event when activity resumes after threshold exceeded. + +### 3.4 Build pipeline + +- **Raycast / Alfred** — separate repos-within-a-monorepo. CI optionally lints (eslint for Raycast; shellcheck for Alfred) but doesn't affect the main release. +- **Widget** — new Swift Package + xcodebuild step in `crates/stint-app/build.rs`. Produces `.appex` (not `.framework`). The `.appex` must end up under `Stint.app/Contents/PlugIns/StintWidget.appex/` at bundle time. Tauri 2 doesn't expose a `bundle.macOS.plugins` config equivalent, so `build.rs` copies the built `.appex` to a stable path (`crates/stint-app/PlugIns/StintWidget.appex`) AND emits the same `bundle.resources` map trick used for the App Intents stencil in 6b — mapping each file in the `.appex` to its bundle-relative path under `PlugIns/StintWidget.appex/`. Plan covers the exact bundle.resources entries (it's ~6 files: Info.plist, the binary, Resources/Assets.car, Metadata.appintents, _CodeSignature/CodeResources, and any localized strings). Fallback if that path doesn't work: a post-`cargo tauri build` script that copies the `.appex` directly into the produced bundle before signing. +- **Idle detection** — pure Rust, no build-pipeline changes beyond a `core-graphics` dependency. + +## 4. Raycast extension + +### 4.1 Commands + +| Command | Type | Args / UI | Action | +|---|---|---|---| +| **Start Timer** | Form | Description (text, required), Project (dropdown, loaded async via `stint --json projects list`), Task (dropdown, filtered by selected project, loaded via `stint --json projects list-tasks ` — new CLI subcommand added in 6c, see §2.1), Billable (toggle) | `stint --json start --description … [--project …] [--task …] [--billable]` | +| **Stop Timer** | No-view | — | `stint --json stop`; toasts the stopped entry's duration | +| **Current Timer** | Detail | Description, project, elapsed (live-updated every 5s while open) | `stint --json current` polled | +| **Recent Entries** | List | Last 50 entries via `stint --json list --limit 50`. Each row's actions: Restart (calls `stint --json restart `), Copy description, Open in Stint (`stint://entry/`) | per-action | +| **Switch Project** | Form | Project (dropdown, required) → stop + start preserving description | `stint --json stop` → `stint --json start` | + +### 4.2 Preferences (Raycast standard prefs schema) + +| Pref | Default | Purpose | +|---|---|---| +| **Stint binary path** | auto-detect | Override box. Auto-detect order: `which stint` → `~/.cargo/bin/stint` → `/Applications/Stint.app/Contents/MacOS/stint`. Useful for users with stint in a non-standard location. | + +### 4.3 Error model + +- CLI non-zero exit → parse stderr → `showToast({ style: Failure, title: "Stint", message: })`. +- "Timer already running" / similar `Invariant` errors are surfaced verbatim (the CLI's `--json` mode prints structured errors via the same envelope shape as MCP). +- Binary not found at the configured path → modal with "Open preferences" action. + +### 4.4 Out of scope (in Raycast) + +- Backdated entries / `start_at` arg — power users can use the CLI directly. +- Reporting / weekly summary commands — defer to a follow-up if requested. + +## 5. Alfred workflow + +### 5.1 Keywords + +| Keyword | Type | Behavior | +|---|---|---| +| `s ` | Run script (with arg) | Starts a timer with the given description. Project + billable take defaults from settings if available; argument is the only required input. | +| `sstop` | Run script | Stops current timer; shows large-type duration. | +| `scur` | Script filter | One result row: the running entry (or "no active timer"). ⏎ opens Stint app via `open stint://current`. | +| `srec` | Script filter | Last 20 entries. ⏎ restarts (calls `stint --json restart `). ⌥⏎ opens via `stint://entry/`. | + +### 5.2 Bundle metadata (`info.plist`) + +- `bundleid` = `tech.reyem.stint.alfred` +- `name` = "Stint" +- `description` = "Start, stop, and inspect Stint time entries from Alfred." +- `version` matches stint app version at release time. + +### 5.3 Binary discovery (matches Raycast) + +Scripts honor `$STINT_BIN` env var if set in the workflow's Workflow Environment Variables. Otherwise fall back to `which stint` → `~/.cargo/bin/stint` → `/Applications/Stint.app/Contents/MacOS/stint`. + +### 5.4 Out of scope (in Alfred) + +- Form-style multi-field input (Alfred's UX doesn't fit it). Users wanting that reach for Raycast or the popover. +- Project / task pickers — argument-only input. Reach for Raycast for autocomplete. + +## 6. WidgetKit widget + +### 6.1 Widget kinds (user picks at "Add Widget" time via the config sheet) + +1. **Running Timer** — primary display: large elapsed time + entry description + project name. Updates every minute while running, shows "No active timer" otherwise. +2. **Today Total** — primary: sum of today's entries. Small size: just total. Medium size: breakdown by top-3 projects. +3. **This-week Project** — total for a *specific* project this week (project chosen per widget). Small: hours number. Medium: bar chart by day. + +### 6.2 Sizes supported + +- `.systemSmall` +- `.systemMedium` + +Skipped: `.systemLarge` (overkill), `.accessoryRectangular` / `.accessoryInline` (macOS lock-screen support sparse). + +### 6.3 Configuration intent + +```swift +struct WidgetConfigIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Configure Stint Widget" + + @Parameter(title: "Show") + var kind: WidgetKind + + @Parameter(title: "Project") + var project: ProjectEntity? // only relevant when kind == .weekProject +} + +enum WidgetKind: String, AppEnum { + case runningTimer + case todayTotal + case weekProject +} +``` + +`WidgetConfigurationIntent` is a different code path from the deferred App Intents discovery — it runs inline in the widget gallery, not via `siriactionsd`. Apple supports it cleanly in Tauri-driven builds (verified empirically: this is the same pattern that lots of third-party widget extensions use). + +### 6.4 Data source: loopback HTTP + +The widget process is sandboxed (Apple's WidgetKit container) — it can't: +- Shell out to CLI. +- Link `stint_core` (different process from stint-app). +- Use `dlsym` to find shared symbols. + +It **can** open TCP connections to loopback. Hence: HTTP `/v1/*`. + +**Port discovery:** stint-app writes the bound port to a known file on bind: + +``` +~/Library/Application Support/stint/api.port ← plain text "49792\n" +``` + +The widget's `Provider.timeline(for:in:)`: + +1. Reads `api.port` file. +2. If absent / unreadable → returns a single placeholder timeline entry ("Stint not running") with `stint://current` as the tap deep link. +3. Otherwise hits `http://127.0.0.1:/v1/current` (and `/v1/entries?since=…` for the totals kinds) with a 2s connect timeout. + +### 6.5 Timeline refresh + +- *Running Timer* kind: 60 timeline entries spaced 1 minute apart (covers 1h). At the 50-minute mark, the last entry has refresh policy `.atEnd` which triggers a re-fetch. +- *Today Total* kind: 1 entry valid now, refresh `.after(Date.now + 300)` (5 min). +- *Week Project* kind: same as Today Total (5-min refresh). + +WidgetKit constraint: ~40 entries reliable, 96 max per timeline. Apple may collapse if too aggressive. We stay at 60 for the timer kind — fine in practice. + +### 6.6 Auto-enable HTTP API + +stint-app's setup hook calls a Swift helper (FFI'd from the widget package — small `@_cdecl` exposing `widget_count() -> i32`) to count currently-configured widgets. If ≥1 widget exists AND `api.enabled = false`, automatically flip the setting to true and persist. One-time onboarding step; widget data starts flowing on next refresh. + +### 6.7 Deep-link tap targets + +Tapping a widget runs an associated intent (or opens an URL). We use the URL pattern: + +- Running Timer widget → `stint://entry/` (open the entry's row in Today) +- Today Total → `stint://current` +- Week Project → `stint://project/` + +## 7. Idle detection + +### 7.1 Mechanism + +```rust +extern "C" { + fn CGEventSourceSecondsSinceLastEventType( + source_state_id: i32, // 0 = combined session state + event_type: u32, // u32::MAX = kCGAnyInputEventType + ) -> f64; +} + +fn idle_seconds() -> f64 { + unsafe { CGEventSourceSecondsSinceLastEventType(0, u32::MAX) } +} +``` + +`core-graphics` Rust crate is a thin binding; we use `extern "C"` directly to avoid the dependency footprint. + +### 7.2 State machine + +The detector is a tokio task spawned from `setup()`: + +``` +poll every 60s + if !timer_running: continue // no entry to worry about + let idle = idle_seconds() + + if idle >= threshold AND pending_idle.is_none(): + pending_idle = Some(now() - idle_seconds()) // when idleness began + + if idle < 60 AND pending_idle.is_some(): + // activity resumed + emit "idle:detected" { + idle_started: ISO8601(pending_idle), + idle_seconds: now() - pending_idle, + } + pending_idle = None +``` + +### 7.3 Settings + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `idle.enabled` | bool | `true` | Master toggle | +| `idle.threshold_secs` | u32 | `600` (10 min) | Idle period before prompt fires | + +Both editable in the Settings UI under a new "Idle detection" section. + +### 7.4 Tauri commands + +```rust +#[tauri::command] +async fn idle_keep() -> Result<()> { + // No-op. Banner dismisses; entry continues unchanged. + Ok(()) +} + +#[tauri::command] +async fn idle_discard(idle_started: String) -> Result<()> { + // Stop the entry with end_at = idle_started. The idle gap is excluded. + let store = …; + let running = RunningTimer::new(store.clone()) + .get().await? + .ok_or_else(|| Error::Invariant("no running timer".into()))?; + Entries::new(store.clone()).set_end(&running.local_uuid, &idle_started).await?; + RunningTimer::new(store).clear().await?; + Ok(()) +} + +#[tauri::command] +async fn idle_split(idle_started: String) -> Result<()> { + // Same effect as discard for the storage layer — close entry at idle_started. + // The "restart now" UX (offering a quick-restart form) lives in the UI; + // backend just closes the existing entry. + idle_discard(idle_started).await +} +``` + +### 7.5 UI banner (`ui/src/components/IdleBanner.tsx`) + +``` +┌────────────────────────────────────────────────────────┐ +│ ⏸ You were idle for 14 minutes │ +│ │ +│ [Keep] [Discard 14m] [Discard + restart now] │ +└────────────────────────────────────────────────────────┘ +``` + +Slides down within the existing popover. Listens for the `idle:detected` Tauri event. Auto-dismisses after `idle_keep` is invoked or after 5 minutes of being shown (silent snooze — assume user is now active and not interested). + +"Discard + restart now" opens the popover's start form pre-filled with the previous entry's description and project. + +### 7.6 Edge cases + +- **Sleep / Wake** — `CGEventSourceSecondsSinceLastEventType` counts sleep as "no events". On wake the function returns a large delta; the idle event fires with the correct `idle_started`. Verified pattern; works in production for other trackers (Timing, Toggl). +- **User dismisses banner via Esc and goes idle again** — latest idle period replaces previous in `pending_idle`. User always sees the *most recent* gap. +- **Timer stops via CLI / another surface while banner is visible** — `idle_discard` returns `Invariant("no running timer")` → UI shows benign toast, banner dismisses. +- **`idle.enabled = false`** — detector task runs but skips the threshold check. Cheap (one bool read every 60s). +- **Multiple displays + lock screen** — lock screen = no input events = correctly counts as idle. +- **Multiple stint-app instances (shouldn't happen but)** — each instance runs its own detector. Last-write-wins on the entry. Race acceptable; documented in code. + +## 8. Data flow walkthroughs + +### 8.1 Raycast: "Start Timer" command + +``` +user → Raycast launchbar → ⌥-Space → "stint start" → Enter + ↓ +Raycast renders Start Timer Form + ↓ (Form mounts) +fetchProjects() = spawn `stint --json projects list` + ↓ +user fills description + picks project → Enter + ↓ +spawn `stint --json start --description … --project ` + ↓ (success) +showToast({ style: Success, title: "Tracking 'X' on Acme" }) +Raycast closes +``` + +### 8.2 Widget: timeline refresh + +``` +WidgetKit asks Provider.timeline(for: config, in: context) + ↓ +read ~/Library/Application Support/stint/api.port + ↓ +GET http://127.0.0.1:/v1/current (2s timeout) + ↓ (success) +build TimelineEntry(s) — for runningTimer, 60 entries spaced 1m apart +return Timeline(entries, policy: .atEnd) + ↓ +macOS renders entry at current time +``` + +If port file missing OR HTTP times out: + +``` +return Timeline([placeholder("Stint not running", tap → stint://current)], policy: .after(1m)) +``` + +### 8.3 Idle: detect + recover + +``` +idle detector task (60s poll): + idle = 720s (12 min), threshold = 600s + pending_idle = now - 720s = 12:34:56 + + (user moves mouse) + idle = 3s + pending_idle.is_some() AND idle < 60s → emit + "idle:detected" { idle_started: "12:34:56", idle_seconds: 720 } + +stint-app emits Tauri event + ↓ +ui IdleBanner.tsx receives → renders banner with 3 actions + ↓ +user clicks [Discard 12m] + ↓ +invoke('idle_discard', { idle_started: "12:34:56" }) + ↓ (Rust handler) +stop the running entry with end_at = 12:34:56 +clear running_timer row +sync_queue: enqueue update op + ↓ +UI banner auto-dismisses +entries:changed event fires → Today refetches +``` + +## 9. Error handling + +### 9.1 Raycast / Alfred + +- CLI non-zero exit → parse JSON error envelope from stderr → surface via toast / large-type. +- Binary not found → toast with action "Open preferences". +- Stint app not running (HTTP-dependent commands like a hypothetical "today total" command) — N/A in 6c; all Raycast/Alfred commands use CLI which works headless. + +### 9.2 Widget + +- HTTP fetch fails (port file missing OR connection refused OR timeout) → render placeholder widget ("Stint not running — tap to open"). Tap fires `stint://current`. +- HTTP returns non-200 → render placeholder with error message ("Stint API error"). +- Configuration intent value is `nil` for `weekProject` (user didn't pick a project) → render "Pick a project in settings" placeholder. + +### 9.3 Idle detection + +- `CGEventSourceSecondsSinceLastEventType` returns negative or `f64::NAN` (shouldn't happen, but) → treat as 0 (no idle). +- Tauri command `idle_discard` errors because timer already stopped → benign toast, banner dismisses anyway. +- `idle.threshold_secs` is 0 or absurdly large → clamp to [60, 86400] (1 min to 24h) at read time. + +## 10. Testing strategy + +| Layer | Location | Run via | Coverage tracked | +|---|---|---|---| +| Raycast unit | `raycast-stint/src/*.test.tsx` | `pnpm test` inside raycast-stint/ | Local-only; not in unified report | +| Raycast e2e | manual smoke checklist in `raycast-stint/README.md` | manual | — | +| Alfred | manual smoke checklist in `alfred-stint/README.md` | manual | — | +| Widget Swift unit | `crates/stint-app/swift/StintWidget/Tests/` | `xcodebuild test` | Local-only | +| Widget e2e | manual smoke (Widget Gallery → Add Stint Widget) | manual | — | +| Idle detector Rust | `crates/stint-app/src/idle_detector.rs` inline `#[cfg(test)] mod tests` + integration via `tests/idle_detector.rs` with mock `CGEventSourceSecondsSinceLastEventType` | `cargo test` | Yes (stint-app) | +| Idle Tauri commands | `crates/stint-app/tests/idle_commands.rs` — exercises commands against a tempdir store | `cargo test` | Yes (stint-app) | +| `api.port` file | inline tests in `stint-app/src/http/` | `cargo test` | Yes (stint-app) | + +### 10.1 Coverage discipline + +stint-app currently sits at 83.7%. Adding `idle_detector.rs` (~80 lines) + `commands/idle.rs` (~50 lines) + widget-presence helper (~30 lines) should land above floor without additional work. Swift Widget code is excluded from the unified report (same as StintIntents in 6b). + +### 10.2 What's NOT tested + +- WidgetKit's actual rendering (no headless way to render a widget in CI; Apple's templates can't run unattended). +- Real lock-screen / focus / sleep behavior of idle detection. +- Raycast Store-submission CI lint (delegated to Raycast's own pipeline). + +These are manual-smoke items in the plan's "Pre-merge smoke" section. + +## 11. Trade-offs and deferred work + +| Decision | Trade-off | Deferred alternative | +|---|---|---| +| `.appex` widget with `WidgetConfigurationIntent` | Establishes the Xcode-pipeline pattern that 6b.1 (App Intents Extension) will inherit | Static widget — simpler, no per-instance config | +| HTTP API as widget data source | Requires GUI running; widget shows placeholder otherwise | Direct SQLite read from the widget process — Apple's WidgetKit sandbox makes this hard; the placeholder is the right UX | +| `api.port` file at fixed path | Race window: stint-app might be writing while widget is reading | mtime-based caching with retry; acceptable for plain int | +| Polling every 60s for idle | 60s granularity on idle-start timestamp | Subscribe to `CGEvent` events directly — heavier, no real benefit | +| Idle threshold default 10 min | Matches Toggl/Timing | 5 min — more aggressive but more false positives | +| Discard == Split at the storage layer | "Split" name implies a behavior we're not fully implementing yet | Pre-fill the popover start form with previous entry on "Discard + restart" — captured in §7.5; lives in UI not backend | +| Raycast distribution via local-install initially | Publishing to Raycast Store has its own review cycle; ship the extension internally first | Submit to `raycast/extensions` PR after the extension is battle-tested | +| Alfred publication on GitHub Releases first | Same logic — Alfred Gallery requires curation; ship to GitHub Releases initially | Submit to Alfred Gallery later | + +## 12. Implementation order (preview) + +The plan doc (`docs/superpowers/plans/2026-05-27-stint-phase-6c-power-user-surfaces.md`) sequences the work. High-level order: + +1. **`api.port` file** — stint-app writes/removes on bind. Required by the widget; doesn't depend on anything else. +2. **Idle detector + Tauri commands + Settings UI** — entirely self-contained Rust + small UI. Most testable, no Apple-ecosystem surface risk. +3. **`IdleBanner.tsx` UI** — Tauri event listener + 3 actions. +4. **Raycast extension** — TypeScript, 5 commands. Fully independent. +5. **Alfred workflow** — bash scripts mirroring Raycast functionality. +6. **WidgetKit package + xcodebuild + .appex bundling** — Swift Package, `build.rs` extension to invoke xcodebuild + copy `.appex` into `Contents/PlugIns/`. Hardest task — expect iteration on the build pipeline (similar to 6b's framework wrapping). +7. **WidgetConfigurationIntent + 3 widget kinds + 2 sizes**. +8. **CI gates** — `xcodebuild test` for widget; lint for Raycast; shellcheck for Alfred. +9. **Manual smoke** — checklist exercising all 4 surfaces. +10. **Docs** — extend `SKILL.md` with the new surfaces. README.md + CLAUDE.md roadmap rows for 6c. + +## 13. References + +- Phase 6a CLI (consumed by Raycast/Alfred): `crates/stint-cli/src/cmd/` +- Phase 6a HTTP API (consumed by widget): `crates/stint-app/src/http/handlers.rs` +- Phase 6b URL routes (consumed by widget tap targets + Raycast Recent Entries action): `crates/stint-core/src/url_scheme.rs` +- Phase 6b spec + §1.5 deferred-state context: `docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md` + +Apple references: +- WidgetKit — [`developer.apple.com/documentation/widgetkit`](https://developer.apple.com/documentation/widgetkit) +- `WidgetConfigurationIntent` — [`developer.apple.com/documentation/appintents/widgetconfigurationintent`](https://developer.apple.com/documentation/appintents/widgetconfigurationintent) +- `CGEventSourceSecondsSinceLastEventType` — [`developer.apple.com/documentation/coregraphics/1454545-cgeventsourcesecondssincelasteve`](https://developer.apple.com/documentation/coregraphics/1454545-cgeventsourcesecondssincelasteve) + +Third-party references: +- Raycast Extension docs — [`developers.raycast.com`](https://developers.raycast.com) +- Alfred Workflow Object Reference — [`alfred.app/help/workflows/`](https://www.alfredapp.com/help/workflows/) diff --git a/docs/superpowers/specs/2026-05-28-stint-phase-6d-xcode-extensions-design.md b/docs/superpowers/specs/2026-05-28-stint-phase-6d-xcode-extensions-design.md new file mode 100644 index 0000000..e92860e --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-stint-phase-6d-xcode-extensions-design.md @@ -0,0 +1,307 @@ +# Stint Phase 6d — Xcode-Based Extensions Migration + +**Status:** design +**Date:** 2026-05-28 +**Predecessors:** Phase 6b (`docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md` §1.5), Phase 6c (`docs/superpowers/specs/2026-05-27-stint-phase-6c-power-user-surfaces-design.md` §6) + +--- + +## 1. Why this phase exists + +Two surfaces shipped in 6b + 6c are **structurally complete but functionally inert** because Apple's extension runtime rejects them at bootstrap: + +- **App Intents (Siri / Shortcuts.app / Focus filter UI)** — 6b shipped the Swift code in an embedded framework. Apple's intent indexer (`siriactionsd`) only discovers types declared in the main binary or in a real **App Intents Extension** `.appex` bundle. Framework-embedded intents are invisible to Siri and Shortcuts.app. + +- **WidgetKit widget** — 6c shipped a Swift Package compiled to a `.appex`-shaped bundle. `pluginkit` registers it, `chronod` launches it, but the binary crashes immediately in Apple's private `_EXRunningExtension.sharedInstance()` with `Failed to create running extension of type: 'viewBridgeUI'`. The runtime needs metadata that Xcode's "Widget Extension" target template injects and SPM's `executableTarget` doesn't. + +Both failures share a single root cause: **Apple's extension architecture has runtime-metadata requirements that only Xcode's extension-target templates produce.** This phase replaces the SPM-based Swift build with an Xcode-driven build, unblocking both surfaces in one migration. + +Out of scope: no new user-facing features. This phase makes shipped-but-broken surfaces actually work. + +--- + +## 2. Goals + +- Siri voice ("Hey Siri, start tracking in Stint") works. +- Shortcuts.app discovers and lists stint's App Intents (Start, Stop, Current, List Today, Switch Project, etc.). +- System Settings → Focus → Stint shows the per-focus project picker UI. +- Right-click desktop → Edit Widgets → Stint appears with three configs × two sizes. +- Spotlight indexing continues to work (no regression from current framework path). + +Non-goals: + +- New App Intent types beyond what 6b already defined. +- New widget kinds beyond the three already designed. +- iOS / iPadOS support. +- Replacing `xcodebuild` with a Rust-native implementation. (Discussed in §11.3.) + +--- + +## 3. Architecture + +``` +crates/stint-app/swift/ + xcodegen/ + project.yml # NEW — single source of truth + .gitignore # ignores StintExtensions.xcodeproj/ + + StintExtensionsCore/ # NEW — shared framework target + Sources/ + PortDiscovery.swift # moved from StintWidget/Sources/StintWidget/Models/ + EntryDTO.swift # moved + ProjectDTO.swift # moved + SpotlightIndexer.swift # moved from StintIntents/Sources/StintIntents/Spotlight/ + Entities/ # moved from StintIntents/Sources/StintIntents/Entities/ + Intents/ # moved from StintIntents/Sources/StintIntents/Intents/ + Focus/ # moved from StintIntents/Sources/StintIntents/Focus/ + Bridge/ + RustFFI.swift # moved from StintIntents/Sources/StintIntents/Bridge.swift + IPC/ + SharedContainerMarker.swift # NEW — reads/writes reindex marker file + DarwinNotification.swift # NEW — host-extension wakeup signal + Tests/ # consolidates today's StintIntents/Tests + StintWidget/Tests + + Extensions/ + StintIntentsExtension/ # NEW — App Intents Extension .appex + Info.plist # NSExtensionPointIdentifier = com.apple.appintents-extension + Sources/ + IntentsExtensionMain.swift # @main AppIntentsExtension { var body: ... } + ExtensionLifecycle.swift # observes Darwin notification, drains marker + StintIntentsExtension.entitlements # sandbox + app-group + + StintWidget/ # NEW — Widget Extension .appex + Info.plist # NSExtensionPointIdentifier = com.apple.widgetkit-extension + Sources/ + WidgetMain.swift # moved from StintWidget/Sources/StintWidget/StintWidgetBundle.swift + RunningTimerWidget.swift # moved + Provider.swift # moved + WidgetConfigIntent.swift # moved + Views/ # moved + StintWidget.entitlements # sandbox + app-group + network.client + + StintIntents/ # DELETED + StintWidget/ # DELETED +``` + +The repo loses two SPM package directories (`StintIntents/`, `StintWidget/`) and gains one declarative project file (`xcodegen/project.yml`) plus three Xcode build targets (`StintExtensionsCore`, `StintIntentsExtension`, `StintWidget`). + +--- + +## 4. Build flow + +``` +build.rs (stint-app) + ├─ check xcodegen is installed; on miss emit `cargo:warning=brew install xcodegen` and bail + ├─ run `xcodegen generate` in swift/xcodegen/ → StintExtensions.xcodeproj + ├─ run `xcodebuild build` for scheme StintIntentsExtension → .appex artifact + ├─ run `xcodebuild build` for scheme StintWidget → .appex artifact + ├─ copy both .appex bundles into crates/stint-app/PlugIns/ + ├─ ad-hoc codesign both bundles for local dev + └─ (release path: scripts/build-app-with-widget.sh re-signs with Developer ID + entitlements) +``` + +`cargo:rerun-if-changed=` covers `project.yml` plus all `.swift` files under both extension source trees. + +`STINT_SKIP_SWIFT_BUILD=1` continues to skip the entire Xcode path (useful for stint-core-only iteration). + +--- + +## 5. IPC: host → extension wakeup for Spotlight reindex + +The current dlsym path is synchronous and in-process. The extension path is asynchronous and eventually-consistent (within seconds). Acceptable for Spotlight — it's a search index, not a UI surface. + +**Mechanism:** + +- **App Group ID:** `group.tech.reyem.stint` — declared in both host and extension entitlements. +- **Shared container path:** `~/Library/Group Containers/group.tech.reyem.stint/` +- **Marker file:** `pending-reindex.json` — host writes atomically (write to temp file, rename); contains list of `{local_uuid, op}` entries where op ∈ `{insert, update, delete}`. +- **Darwin notification name:** `tech.reyem.stint.reindex` — host posts via `CFNotificationCenterPostNotification`. Extension registers an observer on launch (any launch — the indexer wakes the extension periodically anyway; the notification is best-effort eagerness). +- **Extension drain logic:** on launch + on notification, read marker file, perform Spotlight upserts/deletes per entry, then clear the file atomically. + +**Rust side replacement:** + +- Today `stint_app::commands::*` calls `dlsym(stint_notify_indexer)` after every entry mutation. +- New helper module `crates/stint-app/src/spotlight_ipc.rs`: + - `push_pending(local_uuid: &str, op: SpotlightOp)` — appends to the marker file in the App Group container, posts the Darwin notification. + - Replaces every existing `stint_notify_indexer` call. +- Drops `init_stint_intents()` and the framework dlsym scaffolding from `main.rs`. + +**Recovery story:** if the extension never wakes (e.g. user disabled background activity for stint), the marker file accumulates. On the next wake, the extension drains the backlog — no data loss, only index staleness. + +--- + +## 6. Migration order + +Each step is independently verifiable. At no point are both the widget AND Spotlight broken simultaneously. + +| Step | What lands | Verification | +|---|---|---| +| **A** | xcodegen `project.yml` + StintExtensionsCore framework target (with the widget-side shared types: PortDiscovery, EntryDTO, ProjectDTO) + StintWidget extension target. Legacy `swift/StintWidget/` package + `swift/StintIntents/` package still in tree, both unreferenced from the new build. | Widget appears in macOS gallery after install + notarize. | +| **B** | Add StintIntentsExtension target to `project.yml`. Move intent type declarations + Entities into StintExtensionsCore (extension target depends on it). Framework path (`swift/StintIntents/` SPM package) still actively building and serving Spotlight via dlsym. | Shortcuts.app discovers stint actions. Spotlight still works via the legacy framework. | +| **C** | Move SpotlightIndexer + Focus + RustFFI bridge into StintExtensionsCore. Add App Group entitlements to host + both extensions. Implement Darwin notification + marker file in `crates/stint-app/src/spotlight_ipc.rs`. Replace every existing `dlsym(stint_notify_indexer)` call with the new helper. | Spotlight indexing continues to work (mutate an entry, wait ~5s, search). | +| **D** | Delete `swift/StintIntents/` package. Delete `swift/StintWidget/` package. Remove framework build path from `build.rs`. Remove `bundle.macOS.frameworks` from `tauri.conf.json`. Remove `init_stint_intents()` + dlsym scaffolding from `main.rs`. | Full workspace test green; coverage script reports no regression. | + +**Branching:** start from `main` after 6c lands. New branch `phase-6d`. Commits land via merge-commit PR to main, following the project ritual. + +--- + +## 7. Tests + +**Unit (xcodebuild test against StintExtensionsCore framework target):** + +- Existing 19 StintIntents tests migrate verbatim — they test pure Swift types (DTO decoding, entity DTOs, etc.) that don't care which target hosts them. +- Existing 5 StintWidget tests (PortDiscovery, DTO coding) migrate verbatim. +- New: `SharedContainerMarkerTests` — write/read JSON atomically, list pending, clear, handle missing-file as empty. +- New: `DarwinNotificationTests` — register observer, post notification, observer fires within 1s (xctest with expectation). + +**Rust integration:** + +- `crates/stint-app/tests/spotlight_ipc.rs` — verify `push_pending()` writes to the expected App Group path, formats JSON correctly, posts the notification without panicking. Uses tempdir + an `STINT_APP_GROUP_DIR_OVERRIDE` env var to avoid touching the real container. + +**Manual smoke (release-quality validation):** + +1. Build + notarize + install to /Applications. +2. `pluginkit -m -p com.apple.widgetkit-extension | grep stint` → widget bundle ID listed. +3. `pluginkit -m -p com.apple.appintents-extension | grep stint` → intents bundle ID listed. +4. Right-click desktop → Edit Widgets → search "Stint" → expect three configs × small/medium. +5. Open Shortcuts.app → search "stint" → expect Start Timer / Stop / Current / List Today / etc. +6. Siri → "start tracking in Stint" → entry begins. +7. System Settings → Focus → pick a focus → Add Filter → expect Stint filter with project picker. +8. Spotlight test: start a timer with description "spec-test-X". Wait 5s. ⌘-Space → "spec-test-X" → entry result appears. + +--- + +## 8. CI changes + +**ci.yml:** + +```yaml +- name: Install XcodeGen + run: brew install xcodegen + +- name: Generate Xcode project + working-directory: crates/stint-app/swift/xcodegen + run: xcodegen generate + +- name: Swift test (StintExtensionsCore) + run: xcodebuild test -scheme StintExtensionsCore \ + -destination 'platform=macOS' \ + -derivedDataPath ./build/derived +``` + +Removes the two separate `Swift test (StintIntents)` and `Swift test (StintWidget)` steps from today's ci.yml — they collapse into one against the shared framework. + +**release-artifacts.yml:** + +```yaml +- name: Install XcodeGen + run: brew install xcodegen + +# (xcodegen generate runs inside cargo build via build.rs, no separate step needed) + +- name: Sign both .appex bundles with Developer ID + entitlements + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + codesign --force --options runtime --timestamp \ + --sign "$APPLE_SIGNING_IDENTITY" \ + --entitlements crates/stint-app/swift/Extensions/StintIntentsExtension/StintIntentsExtension.entitlements \ + "$APP_PATH/Contents/PlugIns/StintIntentsExtension.appex" + codesign --force --options runtime --timestamp \ + --sign "$APPLE_SIGNING_IDENTITY" \ + --entitlements crates/stint-app/swift/Extensions/StintWidget/StintWidget.entitlements \ + "$APP_PATH/Contents/PlugIns/StintWidget.appex" + # main binary + bundle re-sign as today (with App Group entitlement added + # to entitlements.plist) +``` + +Removes the framework-signing step and the standalone widget-signing step that 6c added. Replaces with the two-appex signing block above. + +--- + +## 9. Local-dev impact + +`scripts/dev-app.sh` adds a `command -v xcodegen` check up-front; prints `brew install xcodegen` and exits 1 if missing. + +`README.md` first-time-setup section gains `brew install xcodegen` alongside `pnpm` and `cargo install tauri-cli`. + +`STINT_SKIP_SWIFT_BUILD=1` continues to fully skip the Xcode path for non-Swift iterating. + +--- + +## 10. Entitlements + +**`crates/stint-app/entitlements.plist`** (host) gains: + +```xml +com.apple.security.application-groups + + group.tech.reyem.stint + +``` + +**`StintIntentsExtension.entitlements`:** + +```xml +com.apple.security.app-sandbox + +com.apple.security.application-groups + + group.tech.reyem.stint + +``` + +**`StintWidget.entitlements`** (already exists from 6c) adds: + +```xml +com.apple.security.application-groups + + group.tech.reyem.stint + +``` + +--- + +## 11. Trade-offs + open questions + +### 11.1 XcodeGen as a build dependency + +Adding a Homebrew dep for first-time setup. Mitigated by README + dev-script check. XcodeGen is widely used (1Password, Mozilla, Bitwarden) and stable. + +### 11.2 Two `.appex` bundles instead of one framework + +Slightly larger `Stint.app` (each `.appex` carries its own Swift runtime overhead, ~1-2 MB each). Acceptable for the functional gains. + +### 11.3 Could we replace xcodebuild with a Rust library later? + +The metadata Apple's extension runtime consults (`Metadata.appintents/extract.actionsdata` schema, `__TEXT,__appintents_meta` Mach-O section layout, `_EXRunningExtension` registration) is **undocumented and changes between macOS releases**. A Rust replacement would be perpetually catching up to Apple's private contract. Recommendation: don't. After 6d lands, we'll have concrete data about what's in the binaries — revisit only if that contract turns out to be small and stable. + +### 11.4 What if a future macOS release breaks the contract? + +Same exposure we already accept by using Apple's frameworks at all. Mitigation: subscribe to the macOS beta cycle (Apple ships beta SDKs in WWDC); recompile + test before the public release. The 6b framework path had the same risk; this phase doesn't change the exposure surface, only its shape. + +### 11.5 What if XcodeGen project.yml expressiveness runs out? + +If we hit a build setting xcodegen can't express, we can either pin to a specific Xcode-generated `.xcodeproj` for the affected target (committing the file as one-time), or switch to Tuist for that target. Treat as a future maintenance issue, not a blocker. + +--- + +## 12. Success criteria + +- All eight manual smoke tests in §7 pass on a notarized build installed to `/Applications/`. +- Existing test suites (workspace cargo, UI vitest, StintExtensionsCore xcodebuild test) all green. +- Coverage: `scripts/coverage.sh` reports no surface regression below 80%. +- Spotlight indexing of mutated entries observable within 10 seconds. +- The roadmap rows for 6b and 6c flip from "partial" / "shipped with caveat" to fully shipped; 6d ships as "deferred-scope-from-6b+6c resolved". + +--- + +## 13. Out-of-scope reminders + +- No new App Intent types. +- No new widget kinds, sizes, or configurations. +- No iOS / iPadOS port. +- No Rust-native xcodebuild replacement. +- No changes to Raycast extension, Alfred workflow, idle detection, or HTTP API. + +If any of these emerge as worthwhile during execution, they belong in a separate follow-up phase. diff --git a/raycast-stint/.gitignore b/raycast-stint/.gitignore new file mode 100644 index 0000000..9451024 --- /dev/null +++ b/raycast-stint/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store +*.log diff --git a/raycast-stint/README.md b/raycast-stint/README.md new file mode 100644 index 0000000..7c24dd7 --- /dev/null +++ b/raycast-stint/README.md @@ -0,0 +1,33 @@ +# Stint for Raycast + +Five commands to drive [stint](https://github.com/reyemtech/stint) time +tracking from Raycast. + +## Install + +Until this is in the Raycast Store, install locally: + +1. Clone the stint repo. +2. From this directory, `pnpm install --ignore-workspace`. + (The repo's pnpm-workspace.yaml covers `ui/` and `site/` only — this + extension is intentionally outside the workspace so it can ship as a + standalone Raycast package.) +3. In Raycast, run "Import Extension" and select the `raycast-stint/` + folder. + +## Configure + +The extension needs the `stint` CLI in your `PATH` or specified in +Raycast preferences. Default discovery order: + +- `/usr/local/bin/stint` +- `~/.cargo/bin/stint` +- `/Applications/Stint.app/Contents/MacOS/stint` + +## Commands + +- **Start Timer** — Form with description, project, task, billable +- **Stop Timer** — One-shot stop +- **Current Timer** — Inspect the running entry +- **Recent Entries** — Browse and restart +- **Switch Project** — Stop and start on a different project diff --git a/raycast-stint/assets/icon.png b/raycast-stint/assets/icon.png new file mode 100644 index 0000000..223535d Binary files /dev/null and b/raycast-stint/assets/icon.png differ diff --git a/raycast-stint/package.json b/raycast-stint/package.json new file mode 100644 index 0000000..0f74d6a --- /dev/null +++ b/raycast-stint/package.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "stint", + "title": "Stint", + "description": "Start, stop, and inspect Stint time entries from Raycast.", + "icon": "icon.png", + "author": "reyemtech", + "categories": ["Productivity"], + "license": "MIT", + "commands": [ + { + "name": "start-timer", + "title": "Start Timer", + "description": "Start a new time entry", + "mode": "view" + }, + { + "name": "stop-timer", + "title": "Stop Timer", + "description": "Stop the running timer", + "mode": "no-view" + }, + { + "name": "current", + "title": "Current Timer", + "description": "Show the running timer", + "mode": "view" + }, + { + "name": "recent-entries", + "title": "Recent Entries", + "description": "Browse and restart recent entries", + "mode": "view" + }, + { + "name": "switch-project", + "title": "Switch Project", + "description": "Stop current and start on a different project", + "mode": "view" + } + ], + "preferences": [ + { + "name": "stintBin", + "type": "textfield", + "title": "Stint binary path", + "description": "Path to the stint CLI. Leave empty to auto-detect.", + "required": false, + "default": "" + } + ], + "dependencies": { + "@raycast/api": "^1.85.0", + "@raycast/utils": "^1.17.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "^22.0.0", + "@types/react": "^18.3.3", + "eslint": "^8.57.1", + "prettier": "^3.3.3", + "typescript": "^5.5.4" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop", + "lint": "ray lint", + "publish": "npx @raycast/api@latest publish" + } +} diff --git a/raycast-stint/pnpm-lock.yaml b/raycast-stint/pnpm-lock.yaml new file mode 100644 index 0000000..73690e5 --- /dev/null +++ b/raycast-stint/pnpm-lock.yaml @@ -0,0 +1,2263 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@raycast/api': + specifier: ^1.85.0 + version: 1.104.19(@types/node@22.19.19)(@types/react@18.3.29) + '@raycast/utils': + specifier: ^1.17.0 + version: 1.19.1(@raycast/api@1.104.19(@types/node@22.19.19)(@types/react@18.3.29)) + devDependencies: + '@raycast/eslint-config': + specifier: ^1.0.11 + version: 1.0.11(eslint@8.57.1)(prettier@3.8.3)(typescript@5.9.3) + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@types/react': + specifier: ^18.3.3 + version: 18.3.29 + eslint: + specifier: ^8.57.1 + version: 8.57.1 + prettier: + specifier: ^3.3.3 + version: 3.8.3 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oclif/core@4.11.4': + resolution: {integrity: sha512-URwiQ5ALx/sJ2iH4vzXEd+H4K6NAI7LRs6Jag3hrgKEpGmaE6alfRC8qjO4GIgb6A3ACaJumqP9twi/M9ywdHQ==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-autocomplete@3.2.50': + resolution: {integrity: sha512-SQRIJSYue/1tIn7X55W/97gTb8UkSoHeFAcBng2r2YMJyWj8uB1DtFl28D8BDXPQXPTiPK89hQGejoT7RdkR2w==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-help@6.2.49': + resolution: {integrity: sha512-fEsO0YU7ThtzHE1RGuoHxFu/OGlqxm7PCfFp+U1PS8sde4E0cDqjVDuv78+VKrr45LpC5lWOApj7pm3FNfHrVA==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-not-found@3.2.86': + resolution: {integrity: sha512-BJhJSahwsYayZpo18f0fPTg8tKb9dIvydaz03NCK3eMfmcsT1MmXhXqh1KEV8J7mz0sQ6f0qFEb6BXy490/iUg==} + engines: {node: '>=18.0.0'} + + '@raycast/api@1.104.19': + resolution: {integrity: sha512-SAVg56BAzxZGy/OPQ0jekUG3pJaoX5pCqleALvFo9JRE7P2tvKoglWnYRcIJArRhLlvV8FtFNMDafd+NNwXXCw==} + engines: {node: '>=22.22.2'} + hasBin: true + peerDependencies: + '@types/node': 22.19.17 + '@types/react': 19.0.10 + react-devtools: 6.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + '@types/react': + optional: true + react-devtools: + optional: true + + '@raycast/eslint-config@1.0.11': + resolution: {integrity: sha512-I0Lt8bwahVGkANUBxripIxKptMBz1Ou+UXGwfqgFvKwo1gVLrnlEngxaspQJA8L5pvzQkQMwizVCSgNC3bddWg==} + peerDependencies: + eslint: '>=7' + prettier: '>=2' + typescript: '>=4' + + '@raycast/eslint-plugin@1.0.16': + resolution: {integrity: sha512-OyFL/W75/4hlgdUUI80Eoes0HjpVrJ8I1kB/PBH2RLjbcK22TC6IwZPXvhBZ5jF962O1TqtOuHrTjySwDaa/cQ==} + peerDependencies: + eslint: '>=7' + + '@raycast/utils@1.19.1': + resolution: {integrity: sha512-/udUGcTZCgZZwzesmjBkqG5naQZTD/ZLHbqRwkWcF+W97vf9tr9raxKyQjKsdZ17OVllw2T3sHBQsVUdEmCm2g==} + peerDependencies: + '@raycast/api': '>=1.69.0' + + '@rushstack/eslint-patch@1.16.1': + resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react@18.3.29': + resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@5.62.0': + resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@5.62.0': + resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@5.62.0': + resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@5.62.0': + resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@5.62.0': + resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansis@3.17.0: + resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + clean-stack@3.0.1: + resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} + engines: {node: '>=10'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@9.1.2: + resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-levenshtein@3.0.0: + resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react@19.0.0: + resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tsutils@3.21.0: + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@8.1.1) + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@22.19.19)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.19) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.19) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/confirm@5.1.21(@types/node@22.19.19)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.19) + '@inquirer/type': 3.0.10(@types/node@22.19.19) + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/core@10.3.2(@types/node@22.19.19)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.19) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/editor@4.2.23(@types/node@22.19.19)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.19) + '@inquirer/external-editor': 1.0.3(@types/node@22.19.19) + '@inquirer/type': 3.0.10(@types/node@22.19.19) + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/expand@4.0.23(@types/node@22.19.19)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.19) + '@inquirer/type': 3.0.10(@types/node@22.19.19) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/external-editor@1.0.3(@types/node@22.19.19)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@22.19.19)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.19) + '@inquirer/type': 3.0.10(@types/node@22.19.19) + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/number@3.0.23(@types/node@22.19.19)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.19) + '@inquirer/type': 3.0.10(@types/node@22.19.19) + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/password@4.0.23(@types/node@22.19.19)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.19) + '@inquirer/type': 3.0.10(@types/node@22.19.19) + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/prompts@7.10.1(@types/node@22.19.19)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.19.19) + '@inquirer/confirm': 5.1.21(@types/node@22.19.19) + '@inquirer/editor': 4.2.23(@types/node@22.19.19) + '@inquirer/expand': 4.0.23(@types/node@22.19.19) + '@inquirer/input': 4.3.1(@types/node@22.19.19) + '@inquirer/number': 3.0.23(@types/node@22.19.19) + '@inquirer/password': 4.0.23(@types/node@22.19.19) + '@inquirer/rawlist': 4.1.11(@types/node@22.19.19) + '@inquirer/search': 3.2.2(@types/node@22.19.19) + '@inquirer/select': 4.4.2(@types/node@22.19.19) + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/rawlist@4.1.11(@types/node@22.19.19)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.19) + '@inquirer/type': 3.0.10(@types/node@22.19.19) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/search@3.2.2(@types/node@22.19.19)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.19) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.19) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/select@4.4.2(@types/node@22.19.19)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.19) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.19) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.19 + + '@inquirer/type@3.0.10(@types/node@22.19.19)': + optionalDependencies: + '@types/node': 22.19.19 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oclif/core@4.11.4': + dependencies: + ansi-escapes: 4.3.2 + ansis: 3.17.0 + clean-stack: 3.0.1 + cli-spinners: 2.9.2 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + get-package-type: 0.1.0 + indent-string: 4.0.0 + is-wsl: 2.2.0 + lilconfig: 3.1.3 + minimatch: 10.2.5 + semver: 7.8.1 + string-width: 4.2.3 + supports-color: 8.1.1 + tinyglobby: 0.2.16 + widest-line: 3.1.0 + wordwrap: 1.0.0 + wrap-ansi: 7.0.0 + + '@oclif/plugin-autocomplete@3.2.50': + dependencies: + '@oclif/core': 4.11.4 + ansis: 3.17.0 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + transitivePeerDependencies: + - supports-color + + '@oclif/plugin-help@6.2.49': + dependencies: + '@oclif/core': 4.11.4 + + '@oclif/plugin-not-found@3.2.86(@types/node@22.19.19)': + dependencies: + '@inquirer/prompts': 7.10.1(@types/node@22.19.19) + '@oclif/core': 4.11.4 + ansis: 3.17.0 + fast-levenshtein: 3.0.0 + transitivePeerDependencies: + - '@types/node' + + '@raycast/api@1.104.19(@types/node@22.19.19)(@types/react@18.3.29)': + dependencies: + '@oclif/core': 4.11.4 + '@oclif/plugin-autocomplete': 3.2.50 + '@oclif/plugin-help': 6.2.49 + '@oclif/plugin-not-found': 3.2.86(@types/node@22.19.19) + esbuild: 0.27.7 + react: 19.0.0 + optionalDependencies: + '@types/node': 22.19.19 + '@types/react': 18.3.29 + transitivePeerDependencies: + - supports-color + + '@raycast/eslint-config@1.0.11(eslint@8.57.1)(prettier@3.8.3)(typescript@5.9.3)': + dependencies: + '@raycast/eslint-plugin': 1.0.16(eslint@8.57.1)(typescript@5.9.3) + '@rushstack/eslint-patch': 1.16.1 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + eslint: 8.57.1 + eslint-config-prettier: 9.1.2(eslint@8.57.1) + prettier: 3.8.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@raycast/eslint-plugin@1.0.16(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.9.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@raycast/utils@1.19.1(@raycast/api@1.104.19(@types/node@22.19.19)(@types/react@18.3.29))': + dependencies: + '@raycast/api': 1.104.19(@types/node@22.19.19)(@types/react@18.3.29) + cross-fetch: 3.2.0 + dequal: 2.0.3 + object-hash: 3.0.0 + signal-exit: 4.1.0 + stream-chain: 2.2.5 + stream-json: 1.9.1 + transitivePeerDependencies: + - encoding + + '@rushstack/eslint-patch@1.16.1': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/prop-types@15.7.15': {} + + '@types/react@18.3.29': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/semver@7.7.1': {} + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3(supports-color@8.1.1) + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + semver: 7.8.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3(supports-color@8.1.1) + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@5.62.0': + dependencies: + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/visitor-keys': 5.62.0 + + '@typescript-eslint/scope-manager@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@5.62.0': {} + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/typescript-estree@5.62.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/visitor-keys': 5.62.0 + debug: 4.4.3(supports-color@8.1.1) + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.8.1 + tsutils: 3.21.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3(supports-color@8.1.1) + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.8.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.1 + '@typescript-eslint/scope-manager': 5.62.0 + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) + eslint: 8.57.1 + eslint-scope: 5.1.1 + semver: 7.8.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.1 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + eslint: 8.57.1 + semver: 7.8.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@5.62.0': + dependencies: + '@typescript-eslint/types': 5.62.0 + eslint-visitor-keys: 3.4.3 + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.1': {} + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansis@3.17.0: {} + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + async@3.2.6: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chardet@2.1.1: {} + + clean-stack@3.0.1: + dependencies: + escape-string-regexp: 4.0.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + deep-is@0.1.4: {} + + dequal@2.0.3: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + emoji-regex@8.0.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@9.1.2(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.1 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@8.1.1) + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.1 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-levenshtein@3.0.0: + dependencies: + fastest-levenshtein: 1.0.16 + + fastest-levenshtein@1.0.16: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.4.2: {} + + fs.realpath@1.0.0: {} + + get-package-type@0.1.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isexe@2.0.0: {} + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.1.1 + + ms@2.1.3: {} + + mute-stream@2.0.0: {} + + natural-compare@1.4.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + object-hash@3.0.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react@19.0.0: {} + + resolve-from@4.0.0: {} + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + semver@7.8.1: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + text-table@0.2.0: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@0.0.3: {} + + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@1.14.1: {} + + tsutils@3.21.0(typescript@5.9.3): + dependencies: + tslib: 1.14.1 + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} diff --git a/raycast-stint/src/current.tsx b/raycast-stint/src/current.tsx new file mode 100644 index 0000000..1b6bbe1 --- /dev/null +++ b/raycast-stint/src/current.tsx @@ -0,0 +1,51 @@ +import { Detail, ActionPanel, Action } from "@raycast/api"; +import { useEffect, useState } from "react"; +import { stint } from "./lib/stint"; +import type { EntryDTO } from "./lib/types"; + +export default function Command() { + const [entry, setEntry] = useState(null); + const [loading, setLoading] = useState(true); + + async function refresh() { + try { + const e = await stint("current"); + setEntry(e); + } finally { + setLoading(false); + } + } + + useEffect(() => { + refresh(); + const id = setInterval(refresh, 5000); + return () => clearInterval(id); + }, []); + + if (loading) return ; + if (!entry) return ; + + const start = new Date(entry.start_at); + const elapsedMins = Math.round((Date.now() - start.getTime()) / 60_000); + const md = `# ${entry.description || "(no description)"} + +**Project:** ${entry.project_id ?? "(none)"} +**Elapsed:** ${elapsedMins} minutes +**Billable:** ${entry.billable ? "yes" : "no"} +**Started:** ${start.toLocaleString()} +`; + + return ( + + + + } + /> + ); +} diff --git a/raycast-stint/src/lib/stint.ts b/raycast-stint/src/lib/stint.ts new file mode 100644 index 0000000..52d3422 --- /dev/null +++ b/raycast-stint/src/lib/stint.ts @@ -0,0 +1,46 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { getPreferenceValues } from "@raycast/api"; + +const execFileAsync = promisify(execFile); + +interface Preferences { + stintBin: string; +} + +let cachedBinPath: string | null = null; + +function resolveBinPath(): string { + const pref = getPreferenceValues().stintBin?.trim(); + if (pref) return pref; + if (cachedBinPath) return cachedBinPath; + + const candidates = [ + "/usr/local/bin/stint", + join(homedir(), ".cargo/bin/stint"), + "/Applications/Stint.app/Contents/MacOS/stint", + ]; + for (const path of candidates) { + if (existsSync(path)) { + cachedBinPath = path; + return path; + } + } + throw new Error( + "stint binary not found. Set the path in Raycast preferences.", + ); +} + +export async function stint(...args: string[]): Promise { + const bin = resolveBinPath(); + const { stdout } = await execFileAsync(bin, ["--json", ...args], { + timeout: 10_000, + maxBuffer: 4 * 1024 * 1024, + }); + const trimmed = stdout.trim(); + if (!trimmed) return undefined as T; + return JSON.parse(trimmed) as T; +} diff --git a/raycast-stint/src/lib/types.ts b/raycast-stint/src/lib/types.ts new file mode 100644 index 0000000..f5bf7e8 --- /dev/null +++ b/raycast-stint/src/lib/types.ts @@ -0,0 +1,26 @@ +export interface EntryDTO { + local_uuid: string; + solidtime_id: string | null; + description: string; + project_id: string | null; + task_id: string | null; + billable: boolean; + start_at: string; + end_at: string | null; + source: string; +} + +export interface ProjectDTO { + solidtime_id: string; + name: string; + color: string | null; + client_id: string | null; + archived: boolean; +} + +export interface TaskDTO { + solidtime_id: string; + project_id: string; + name: string; + done: boolean; +} diff --git a/raycast-stint/src/recent-entries.tsx b/raycast-stint/src/recent-entries.tsx new file mode 100644 index 0000000..548da25 --- /dev/null +++ b/raycast-stint/src/recent-entries.tsx @@ -0,0 +1,64 @@ +import { List, ActionPanel, Action, showToast, Toast } from "@raycast/api"; +import { useEffect, useState } from "react"; +import { stint } from "./lib/stint"; +import type { EntryDTO } from "./lib/types"; + +export default function Command() { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + stint("list", "--limit", "50") + .then(setEntries) + .catch((e) => + showToast({ + style: Toast.Style.Failure, + title: "Failed", + message: String(e), + }), + ) + .finally(() => setLoading(false)); + }, []); + + async function handleRestart(entry: EntryDTO) { + try { + await stint("restart", entry.local_uuid); + await showToast({ + style: Toast.Style.Success, + title: `Restarted '${entry.description}'`, + }); + } catch (e) { + await showToast({ + style: Toast.Style.Failure, + title: "Restart failed", + message: String(e), + }); + } + } + + return ( + + {entries.map((e) => ( + + handleRestart(e)} /> + + + + } + /> + ))} + + ); +} diff --git a/raycast-stint/src/start-timer.tsx b/raycast-stint/src/start-timer.tsx new file mode 100644 index 0000000..1f246cf --- /dev/null +++ b/raycast-stint/src/start-timer.tsx @@ -0,0 +1,115 @@ +import { + Form, + ActionPanel, + Action, + Toast, + showToast, + popToRoot, +} from "@raycast/api"; +import { useState, useEffect } from "react"; +import { stint } from "./lib/stint"; +import type { ProjectDTO, TaskDTO, EntryDTO } from "./lib/types"; + +interface FormValues { + description: string; + project_id: string; + task_id: string; + billable: boolean; +} + +export default function Command() { + const [projects, setProjects] = useState([]); + const [tasks, setTasks] = useState([]); + const [selectedProject, setSelectedProject] = useState(""); + const [loadingProjects, setLoadingProjects] = useState(true); + const [loadingTasks, setLoadingTasks] = useState(false); + + useEffect(() => { + stint("projects", "list") + .then((list) => setProjects(list.filter((p) => !p.archived))) + .catch((e) => + showToast({ + style: Toast.Style.Failure, + title: "Failed to load projects", + message: String(e), + }), + ) + .finally(() => setLoadingProjects(false)); + }, []); + + useEffect(() => { + if (!selectedProject) { + setTasks([]); + return; + } + setLoadingTasks(true); + stint("projects", "list-tasks", selectedProject) + .then((list) => setTasks(list.filter((t) => !t.done))) + .catch(() => setTasks([])) + .finally(() => setLoadingTasks(false)); + }, [selectedProject]); + + async function handleSubmit(values: FormValues) { + try { + const args = ["start", "--description", values.description]; + if (values.project_id) args.push("--project", values.project_id); + if (values.task_id) args.push("--task", values.task_id); + if (values.billable) args.push("--billable"); + const entry = await stint(...args); + await showToast({ + style: Toast.Style.Success, + title: `Tracking '${entry.description}'`, + }); + await popToRoot(); + } catch (e) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to start timer", + message: String(e), + }); + } + } + + return ( +
+ + + } + > + + + + {projects.map((p) => ( + + ))} + + + + {tasks.map((t) => ( + + ))} + + + + ); +} diff --git a/raycast-stint/src/stop-timer.tsx b/raycast-stint/src/stop-timer.tsx new file mode 100644 index 0000000..3f0aff4 --- /dev/null +++ b/raycast-stint/src/stop-timer.tsx @@ -0,0 +1,23 @@ +import { showToast, Toast } from "@raycast/api"; +import { stint } from "./lib/stint"; +import type { EntryDTO } from "./lib/types"; + +export default async function Command() { + try { + const entry = await stint("stop"); + const start = new Date(entry.start_at); + const end = entry.end_at ? new Date(entry.end_at) : new Date(); + const mins = Math.round((end.getTime() - start.getTime()) / 60_000); + await showToast({ + style: Toast.Style.Success, + title: `Stopped (${mins}m)`, + message: entry.description, + }); + } catch (e) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to stop", + message: String(e), + }); + } +} diff --git a/raycast-stint/src/switch-project.tsx b/raycast-stint/src/switch-project.tsx new file mode 100644 index 0000000..1f14c0f --- /dev/null +++ b/raycast-stint/src/switch-project.tsx @@ -0,0 +1,96 @@ +import { + Form, + ActionPanel, + Action, + showToast, + Toast, + popToRoot, +} from "@raycast/api"; +import { useEffect, useState } from "react"; +import { stint } from "./lib/stint"; +import type { ProjectDTO, EntryDTO } from "./lib/types"; + +export default function Command() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [current, setCurrent] = useState(null); + + useEffect(() => { + Promise.all([ + stint("projects", "list"), + stint("current"), + ]) + .then(([p, c]) => { + setProjects(p.filter((x) => !x.archived)); + setCurrent(c); + }) + .catch((e) => + showToast({ + style: Toast.Style.Failure, + title: "Failed to load", + message: String(e), + }), + ) + .finally(() => setLoading(false)); + }, []); + + async function handleSubmit(values: { project_id: string }) { + if (!current) { + await showToast({ + style: Toast.Style.Failure, + title: "No timer to switch from", + }); + return; + } + try { + await stint("stop"); + await stint( + "start", + "--description", + current.description, + "--project", + values.project_id, + ); + const proj = projects.find((p) => p.solidtime_id === values.project_id); + await showToast({ + style: Toast.Style.Success, + title: `Switched to ${proj?.name ?? values.project_id}`, + }); + await popToRoot(); + } catch (e) { + await showToast({ + style: Toast.Style.Failure, + title: "Switch failed", + message: String(e), + }); + } + } + + return ( +
+ + + } + > + + + {projects.map((p) => ( + + ))} + + + ); +} diff --git a/raycast-stint/tsconfig.json b/raycast-stint/tsconfig.json new file mode 100644 index 0000000..b87f5ec --- /dev/null +++ b/raycast-stint/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "include": ["src/**/*"], + "compilerOptions": { + "lib": ["ES2023"], + "module": "commonjs", + "target": "ES2022", + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "resolveJsonModule": true + } +} diff --git a/scripts/build-app-with-widget.sh b/scripts/build-app-with-widget.sh new file mode 100755 index 0000000..36953de --- /dev/null +++ b/scripts/build-app-with-widget.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Build Stint.app and relocate the embedded StintWidget.appex into +# Contents/PlugIns/ where macOS's WidgetKit looks for widget extensions. +# +# Why this script exists: +# Tauri's bundle.resources places files under Contents/Resources/. Apple +# requires .appex extensions at Contents/PlugIns/.appex. We let +# build.rs produce the .appex into crates/stint-app/PlugIns/ (gitignored) +# and this wrapper moves it into the right place inside the bundled .app +# post-build, then re-signs. +# +# Usage: +# scripts/build-app-with-widget.sh # ad-hoc sign (dev / local install) +# scripts/build-app-with-widget.sh "Developer ID Application: ..." # release sign + +set -euo pipefail + +readonly REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +readonly SIGN_IDENTITY="${1:--}" # default: "-" = ad-hoc +readonly APP="target/release/bundle/macos/Stint.app" +readonly SRC_APPEX="crates/stint-app/PlugIns/StintWidget.appex" +readonly DEST_APPEX="$APP/Contents/PlugIns/StintWidget.appex" + +echo "==> Building Stint.app" +cargo tauri build --bundles app + +if [[ ! -d "$SRC_APPEX" ]]; then + echo "ERROR: $SRC_APPEX missing — build.rs did not produce the widget appex" + exit 1 +fi + +echo "==> Relocating StintWidget.appex into Contents/PlugIns/" +mkdir -p "$(dirname "$DEST_APPEX")" +rm -rf "$DEST_APPEX" +cp -R "$SRC_APPEX" "$DEST_APPEX" + +# Strip the Resources/PlugIns duplicate that Tauri's bundle step would +# otherwise leave behind (harmless but doubles the dylib). +rm -rf "$APP/Contents/Resources/PlugIns" + +echo "==> Re-signing embedded StintIntents framework (build.rs ad-hoc only)" +codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \ + "$APP/Contents/Frameworks/StintIntents.framework" + +echo "==> Signing $DEST_APPEX with $SIGN_IDENTITY (sandboxed)" +codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \ + --entitlements crates/stint-app/swift/StintWidget/StintWidget.entitlements \ + "$DEST_APPEX" + +echo "==> Re-signing main bundle to seal the new PlugIns/ + Frameworks/" +codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \ + --entitlements crates/stint-app/entitlements.plist \ + "$APP/Contents/MacOS/stint-app" +codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \ + --entitlements crates/stint-app/entitlements.plist \ + "$APP" + +echo "==> Verifying signature" +codesign --verify --deep --strict --verbose=2 "$APP" 2>&1 | tail -3 + +echo "==> Done. Bundle at $APP" diff --git a/scripts/coverage.sh b/scripts/coverage.sh index c196f8b..44247e4 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -43,11 +43,16 @@ if [[ "$SKIP_RUST" != "1" ]]; then # calendar_worker.rs spawn/select! plumbing isn't unit-testable # * commands/ui.rs — window/dock visibility shims that delegate to # Tauri APIs requiring a real WebviewWindow - # * updater.rs — Tauri-updater plugin wrapper; needs a signed - # build + remote release server to exercise + # * updater.rs / — Tauri-updater plugin wrapper; needs a signed + # updater_endpoint.rs build + remote release server to exercise + # * idle_detector.rs — CGEventSource-backed polling task + tokio + # spawn loop. The pure state machine (advance) + # IS verified by tests/idle_detector.rs, but + # the polling side isn't unit-testable without + # a live AppHandle. # stint-app excludes: Tauri runtime wiring (main, menu, tray, workers, etc.) # exercises native macOS APIs and the Tauri event loop — not unit-testable. - APP_RE='stint-app/src/(main|menu|tray|windows|logging|app_state|sync_worker|pull_worker|calendar_worker|updater)\.rs|stint-app/src/commands/ui\.rs' + APP_RE='stint-app/src/(main|menu|tray|windows|logging|app_state|sync_worker|pull_worker|calendar_worker|updater|updater_endpoint|idle_detector)\.rs|stint-app/src/commands/ui\.rs' # stint-cli excludes: subprocess- and OAuth-bound surfaces. # * cmd/mcp.rs / mcp/mod.rs — `stint mcp` runs as a subprocess; covered # by tests/mcp_e2e.rs but the child-process diff --git a/site/src/content/docs/help/faq.mdx b/site/src/content/docs/help/faq.mdx index e7bfd14..6f8ac79 100644 --- a/site/src/content/docs/help/faq.mdx +++ b/site/src/content/docs/help/faq.mdx @@ -252,6 +252,8 @@ Yes. Stint.app registers the `stint://` URL scheme on macOS: | `stint://stop` | Stop the running timer | | `stint://current` | Focus the current-entry view | | `stint://entry/` | Open an entry in the main window | +| `stint://project/` | Open the Today view focused on a project | +| `stint://task/` | Open Today filtered to a task (resolves parent project automatically) | `open "stint://stop"` from the shell, or wire into Raycast / Alfred / Shortcuts. diff --git a/ui/src/api.ts b/ui/src/api.ts index c6f0d40..636da6d 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -12,6 +12,7 @@ import type { Project, RunningTimer, SyncError, + Task, } from "./types"; export const api = { @@ -41,6 +42,8 @@ export const api = { invoke("update_description", { localUuid, description }), setEntryProject: (localUuid: string, projectId: string | null) => invoke("set_entry_project", { localUuid, projectId }), + setEntryTask: (localUuid: string, taskId: string | null) => + invoke("set_entry_task", { localUuid, taskId }), setEntryBillable: (localUuid: string, billable: boolean) => invoke("set_entry_billable", { localUuid, billable }), updateEntryTimes: (localUuid: string, startAt: string, endAt: string) => @@ -51,6 +54,11 @@ export const api = { invoke("list_between", { from, to }), listProjects: () => invoke("list_projects"), + /// Pass `projectId` to scope to one project (the picker always does). Omit + /// to list every locally-cached task — handy for admin reads, but the UI + /// picker never calls it without a project. + listTasks: (projectId?: string | null) => + invoke("list_tasks", { projectId: projectId ?? null }), refreshProjects: () => invoke("refresh_projects"), listOrganizations: () => invoke("list_organizations"), @@ -64,6 +72,12 @@ export const api = { listSyncErrors: () => invoke("list_sync_errors"), getSyncErrorOverlaps: (localUuid: string) => invoke("get_sync_error_overlaps", { localUuid }), + + idleKeep: () => invoke("idle_keep"), + idleDiscard: (idleStarted: string) => + invoke("idle_discard", { idleStarted }), + idleSplit: (idleStarted: string) => + invoke("idle_split", { idleStarted }), }; export type SolidtimeAuthStatus = { diff --git a/ui/src/components/EditEntryDialog.tsx b/ui/src/components/EditEntryDialog.tsx index 58c7a0e..f1409e2 100644 --- a/ui/src/components/EditEntryDialog.tsx +++ b/ui/src/components/EditEntryDialog.tsx @@ -4,6 +4,7 @@ import { fromLocalHHMM, toLocalHHMM } from "~/lib/entryFormat"; import type { Entry } from "~/types"; import Button from "./ui/Button"; import ProjectPicker from "./ui/ProjectPicker"; +import TaskPicker from "./ui/TaskPicker"; import Toggle from "./ui/Toggle"; export default function EditEntryDialog(props: { @@ -15,6 +16,7 @@ export default function EditEntryDialog(props: { const [projectId, setProjectId] = createSignal( props.entry.project_id, ); + const [taskId, setTaskId] = createSignal(props.entry.task_id); const [billable, setBillable] = createSignal(props.entry.billable); const startHHMMInitial = toLocalHHMM(props.entry.start_at); const endHHMMInitial = props.entry.end_at @@ -28,6 +30,14 @@ export default function EditEntryDialog(props: { const [projects] = createResource(() => api.listProjects(), { initialValue: [], }); + // Tasks for the currently-selected project. Re-fetches when projectId + // flips. An empty projectId resolves to an empty list and the TaskPicker + // stays disabled — no point hitting the IPC. + const [tasks] = createResource( + () => projectId(), + async (pid) => (pid ? await api.listTasks(pid) : []), + { initialValue: [] }, + ); const isCompleted = createMemo(() => Boolean(props.entry.end_at)); @@ -40,6 +50,9 @@ export default function EditEntryDialog(props: { if (projectId() !== props.entry.project_id) { await api.setEntryProject(props.entry.local_uuid, projectId()); } + if (taskId() !== props.entry.task_id) { + await api.setEntryTask(props.entry.local_uuid, taskId()); + } if (billable() !== props.entry.billable) { await api.setEntryBillable(props.entry.local_uuid, billable()); } @@ -108,7 +121,13 @@ export default function EditEntryDialog(props: {
{ + // Tasks scope to projects — changing project must discard + // the staged task selection so Save doesn't send a + // task_id that doesn't belong to the new project. + setTaskId(null); + setProjectId(id); + }} projects={projects() ?? []} placeholder="No project" size="sm" @@ -116,6 +135,22 @@ export default function EditEntryDialog(props: {
+
+ +
+ +
+
+
diff --git a/ui/src/components/EntryList.tsx b/ui/src/components/EntryList.tsx index b3a518a..b2007cb 100644 --- a/ui/src/components/EntryList.tsx +++ b/ui/src/components/EntryList.tsx @@ -5,6 +5,9 @@ import EntryRow from "./EntryRow"; export default function EntryList(props: { entries: Entry[]; + /// When set, the matching entry row scrolls into view + briefly highlights. + /// Driven by `?entry=` in the route (Spotlight deep-link taps). + focusUuid?: string; /// Fires after any save or delete in a row's edit dialog. Callers refetch here. onChange?: () => void; }) { @@ -17,6 +20,25 @@ export default function EntryList(props: { return (id: string | null | undefined) => (id ? map.get(id) : undefined); }); + // Resolve task names by fetching the locally-cached task list for the + // visible day. Only triggers when at least one entry references a task — + // saves an IPC for the common "no tasks yet" case. Tracks every entry's + // task_id (any signal change refires the resource), but the actual fetch + // returns all tasks the local DB knows about in one call. + const needsTasks = createMemo(() => + props.entries.some((e) => e.task_id != null), + ); + const [tasks] = createResource( + needsTasks, + async (need) => (need ? await api.listTasks() : []), + { initialValue: [] }, + ); + const taskName = createMemo(() => { + const map = new Map(); + for (const t of tasks() ?? []) map.set(t.solidtime_id, t.name); + return (id: string | null | undefined) => (id ? map.get(id) : undefined); + }); + return ( 0} @@ -32,7 +54,9 @@ export default function EntryList(props: { )} diff --git a/ui/src/components/EntryRow.tsx b/ui/src/components/EntryRow.tsx index 79ff495..26e36e2 100644 --- a/ui/src/components/EntryRow.tsx +++ b/ui/src/components/EntryRow.tsx @@ -1,4 +1,4 @@ -import { Show, createSignal } from "solid-js"; +import { Show, createEffect, createSignal, onMount } from "solid-js"; import { api } from "~/api"; import { entryDurationSecs, entrySyncMeta } from "~/lib/entryFormat"; import type { Entry } from "~/types"; @@ -10,15 +10,40 @@ import StatusDot from "./ui/StatusDot"; export default function EntryRow(props: { entry: Entry; projectName?: string; + taskName?: string; isFirst?: boolean; + /// When true, scroll this row into view + briefly highlight it (driven + /// by `?entry=` in the URL — Spotlight deep-link taps). + focused?: boolean; /// Fires after any save or delete in the dialog. Callers refetch here. onChange?: () => void; }) { const [editing, setEditing] = createSignal(false); const [restarting, setRestarting] = createSignal(false); + // Holds a temporary "just focused" flag — drives a yellow ring for ~2.5s + // after a deep-link tap so the user can see which row matched. + const [pulse, setPulse] = createSignal(false); + let rowEl: HTMLLIElement | undefined; const isRunning = !props.entry.end_at; const meta = () => entrySyncMeta(props.entry.sync_state, isRunning); + function applyFocusHighlight() { + if (!props.focused || !rowEl) return; + // Defer scroll until layout settles after the route transition. + requestAnimationFrame(() => { + rowEl?.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + setPulse(true); + setTimeout(() => setPulse(false), 2500); + } + + onMount(applyFocusHighlight); + createEffect(() => { + // Re-trigger when props.focused flips true on an existing row (e.g. + // the user taps a different Spotlight result while the view is mounted). + if (props.focused) applyFocusHighlight(); + }); + async function handleRestart() { if (restarting()) return; setRestarting(true); @@ -34,9 +59,12 @@ export default function EntryRow(props: { return (
  • + + +
  • +
    + )} +
    + ); +} diff --git a/ui/src/components/TimerCard.tsx b/ui/src/components/TimerCard.tsx index f0527d4..aca7d35 100644 --- a/ui/src/components/TimerCard.tsx +++ b/ui/src/components/TimerCard.tsx @@ -6,6 +6,7 @@ import Button from "./ui/Button"; import ProjectPicker from "./ui/ProjectPicker"; import SectionLabel from "./ui/SectionLabel"; import StatusDot from "./ui/StatusDot"; +import TaskPicker from "./ui/TaskPicker"; import Toggle from "./ui/Toggle"; import { useTimerStore } from "~/stores/timer"; @@ -13,6 +14,7 @@ export default function TimerCard() { const timer = useTimerStore(); const [description, setDescription] = createSignal(""); const [projectId, setProjectId] = createSignal(""); + const [taskId, setTaskId] = createSignal(null); const [billable, setBillable] = createSignal(false); const [startAt, setStartAt] = createSignal(null); const [projects] = createResource(() => api.listProjects(), { @@ -20,6 +22,25 @@ export default function TimerCard() { }); const projectList = () => projects() ?? []; + // Tasks for the *start form's* selected project. Re-fetched whenever the + // project changes; an empty project resolves to an empty list and the + // TaskPicker stays disabled (no point hitting the IPC). + const [startFormTasks] = createResource( + () => projectId() || null, + async (pid) => (pid ? await api.listTasks(pid) : []), + { initialValue: [] }, + ); + + // Tasks for the *running entry's* project. Same shape, different source — + // the running entry's project_id might differ from the start form's + // (e.g. when the user is editing the live entry's project inline). + const runningProjectId = () => timer.running()?.project_id ?? null; + const [runningTasks] = createResource( + runningProjectId, + async (pid) => (pid ? await api.listTasks(pid) : []), + { initialValue: [] }, + ); + return (
    @@ -57,11 +80,26 @@ export default function TimerCard() {
    setProjectId(id ?? "")} + onChange={(id) => { + // Tasks scope to projects — changing project must + // discard the old task selection or we'd send a + // task_id that doesn't belong to the new project. + setTaskId(null); + setProjectId(id ?? ""); + }} projects={projectList()} placeholder="No project" />
    +
    + +
    +
    + { + await api.setEntryTask(t().local_uuid, id); + await timer.refresh(); + }} + tasks={runningTasks() ?? []} + projectSelected={Boolean(t().project_id)} + placeholder="No task" + size="sm" + /> +
    void; + tasks: Task[]; + /// When false, the input is disabled and the placeholder shifts to a + /// hint that the user must pick a project first. + projectSelected: boolean; + placeholder?: string; + size?: "sm" | "md"; +}) { + const options = createMemo(() => { + const live = props.tasks.map