Skip to content

Phase 6c + task assignment + 6d planning#28

Merged
mariomeyer merged 70 commits into
mainfrom
feature/task-assignment
May 29, 2026
Merged

Phase 6c + task assignment + 6d planning#28
mariomeyer merged 70 commits into
mainfrom
feature/task-assignment

Conversation

@mariomeyer

Copy link
Copy Markdown
Member

Summary

Large batch covering three concurrent threads of work:

  • Phase 6c (Power-user surfaces): Raycast extension (5 commands), Alfred workflow (4 keyword scripts), WidgetKit widget (3 kinds × 2 sizes), idle detection (state machine + polling + UI banner + Settings).
  • Task assignment (parallel subagent work): list_tasks / set_entry_task Tauri commands + CLI subcommand, TaskPicker UI component, task pill on EntryRow, end-to-end propagation of task_id through start/patch flows.
  • Phase 6d planning: spec + implementation plan for migrating the Swift extension build from SPM packages to an xcodegen-driven Xcode project (unblocks both deferred 6b App Intents work and 6c WidgetKit gallery loading).

The widget surface specifically: WidgetKit .appex builds end-to-end, is signed + notarized, and pluginkit registers it — but Apple's _EXRunningExtension runtime rejects the bundle at bootstrap because SPM's executableTarget doesn't emit the metadata Xcode's "Widget Extension" target template would. The 6d phase planned here resolves that gap by switching to an Xcode-based build.

Test plan

  • CI runs cargo workspace tests (20 binaries) + UI vitest (271 tests) + StintIntents Swift tests (19) + StintWidget Swift tests (5) — all green
  • CI clippy -D warnings clean
  • CI rustfmt check clean
  • CI UI typecheck clean
  • Coverage script reports ≥80% on every surface
  • Manual: install local notarized build → Raycast extension + Alfred workflow + idle detection all functional
  • Manual: widget appears in gallery (caveat: currently shows but with the runtime gap noted in 6d spec)
  • Manual: task picker shows up in TimerCard + EditEntryDialog; selecting a task persists + round-trips via HTTP API

Out of scope (deferred to Phase 6d)

🤖 Generated with Claude Code

mariomeyer added 30 commits May 25, 2026 14:14
Lands the Phase 6 design spec covering Spotlight + App Intents (6b) and
outlining Raycast/Alfred/WidgetKit/idle (6c).

Key 6b decisions captured: SPM-built StintIntents.framework embedded via
Tauri bundle, bidirectional FFI bridge (extern "C" verbs + @_cdecl
indexer notify via dlsym), comprehensive CSSearchableIndex schema across
three domain identifiers + NSUserActivity for the running entry, single
Focus filter target (default project for new timers) with
focus-id-reconciled fallback in verbs::start, and five-layer testing
strategy.

Also fixes the dangling spec reference in stint_core::verbs::mod that
pointed to a never-existed path.
Breaks the Phase 6b spec into ~30 task-by-task steps with TDD discipline,
per-task commits, and explicit fallback paths for the known fragility
points (SPM spike outcome, framework wrapping, focus-id read API).

Spec at docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md.
Lands the Package.swift seed for the StintIntents framework and
records the SPM+xcodebuild spike result in the plan.

Spike findings (will inform Tasks G1 + H1 + J3):

- `swift build` alone does NOT run appintentsmetadataprocessor.
  xcodebuild against Package.swift (no .xcodeproj needed) does.
  Build command:
    xcodebuild -scheme StintIntents -configuration Release \
               -destination 'platform=macOS' \
               -derivedDataPath ./build/derived
- Every App Shortcut phrase must contain \(.applicationName) —
  enforced by the processor as a build error.
- Output paths:
    Build/Products/Release/PackageFrameworks/StintIntents.framework/
    Build/Products/Release/StintIntents.appintents/Metadata.appintents/
- The metadata bundle is separate from the framework; build.rs in
  stint-app must copy it into the framework's Versions/A/Resources/
  and inject NSAppIntentsPackage=YES into the framework's Info.plist
  so macOS auto-discovers intents when the framework is embedded in
  Stint.app/Contents/Frameworks/.

A1.fallback (Xcode .xcodeproj) is no longer needed.
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 — never renumber:
  1 = Invariant       (e.g., "a timer is already running")
  2 = NotFound        (lookup miss on uuid/id)
  3 = reserved        (Conflict — no current Error variant maps to it)
  4 = Serialization   (Error::Serde — malformed JSON)
  99 = Internal       (any other typed Error variant)
  -1 = Panic          (catch_unwind caught a panic)

Verb wrappers and settings FFI surfaces land in follow-up tasks; this
commit only proves the envelope shape, panic-recovery, and null-safety
of stint_free_string.
Adds the 8 verb FFI entry points: start, stop, current, list_entries,
list_projects, list_tasks, update_entry, delete_entry. Each takes a
JSON params string (where applicable) and writes the envelope into
out_json. Heap-owned by Rust; caller frees via stint_free_string.

A lazy process-wide multi-threaded tokio runtime + a path-keyed Store
cache let synchronous FFI callers block_on async verbs without seeing
tokio. The cache is keyed by paths::database_path() so tests can swap
STINT_DATA_DIR between runs.

Tests cover happy paths, the already-running invariant, current-when-
idle (returns null inside ok), stop-with-no-timer (errors), update +
delete missing UUIDs (delete is intentionally idempotent — matches the
existing verb contract), and malformed/null JSON paths (codes 4 and 1).
Rounds out the FFI surface beyond the verb wrappers:

- stint_settings_set/get/clear: opaque key/value passthrough used by
  Swift's ProjectFocusFilter to persist the (focus_id, project_id) tuple.
  Get returns *out_json = NULL for absent keys (not an error).

- stint_log_warn: Swift→Rust logging into the "stint_intents" tracing
  target so framework-side warnings show up in stint's existing log
  surface.

- stint_current_focus_id: dlsym lookup for the Swift-exported helper
  stint_current_focus_id_swift. Returns null when the framework isn't
  loaded (CLI binary, headless tests), which the start-verb fallback
  treats as "no current focus".

- notify_indexer (Rust → Swift) + IndexerKind enum: dlsym-lookup of
  swift_indexer_notify, no-op when absent. The verb call sites in a
  follow-up task will hook into this to push Spotlight delta updates.

Adds libc as a direct dep for the dlsym lookups (RTLD_DEFAULT walks the
global symbol table — finds Swift exports when the framework is loaded
into the same process, returns null otherwise).
Every verb mutation path now pushes a Spotlight index delta after a
successful store write:

- verbs::start         → IndexerKind::EntryStarted   (payload: EntryView)
- verbs::stop          → IndexerKind::EntryStopped   (payload: EntryView)
- verbs::update_entry  → IndexerKind::EntryUpdated   (payload: EntryView)
- verbs::delete_entry  → IndexerKind::EntryDeleted   (payload: {"local_uuid": "..."})

stint-app's pull_worker pushes ProjectsReplaced + TasksReplaced after
every successful Solidtime down-sync so the Spotlight project/task
slices stay current with archive state changes from the server.

All notifications are no-ops when the Swift framework isn't loaded
(stint-cli, headless tests) — dlsym lookup returns null and the call
returns early. The 22 existing FFI tests still pass, plus full
stint-core suite green.

Also adds crates/stint-core/include/stint_core.h — hand-written C
header consumed by Swift's bridging module. Mirrors the extern "C"
surface in src/ffi.rs and documents the Swift-side @_cdecl symbols
(stint_intents_init, swift_indexer_notify, stint_current_focus_id_swift)
that Rust looks up via dlsym.
When start() is invoked with no project_id, look up "focus.default_project"
(a tab-separated "<focus_id>\t<project_id>" tuple written by Swift's
ProjectFocusFilter) and apply it only when the stored focus_id matches
the currently active macOS focus. Prevents stale defaults from leaking
after the user switches focus modes.

crates/stint-core/src/focus.rs is the lookup helper. Production path:
dlsym into Swift's stint_current_focus_id_swift (no-op when framework
absent). Test path: STINT_TEST_FOCUS_ID env var short-circuits the
dlsym lookup so integration tests can exercise the fallback without a
Swift runtime.

Six tests cover: explicit project wins, focus_id match applies default,
focus_id mismatch ignores stale default, no-stored-default no-ops, no-
active-focus no-ops, malformed "<no-tab>" entry no-ops.
Extends url_scheme::parse to recognize stint://project/<id> and
stint://task/<id>, used by Spotlight result taps in Phase 6b.

The Tauri deep-link handler in stint-app emits a navigate event with
the route string (matching the existing /today, /settings, /about
contract used by the tray menu). For tasks, the handler resolves
task → parent project via verbs::list_tasks so the navigation URL
carries both project= and task= query params.

5 new URL parser tests cover happy paths + missing-id error paths for
both routes. URL scheme tests total 15; full stint-core suite green.
…Focus filter

Complete Swift package at crates/stint-app/swift/StintIntents/:

- Bridge.swift: @_silgen_name forward declarations of stint-core's C ABI
  (resolved at app-load time via -undefined dynamic_lookup) + DTOs +
  Bridge protocol + production FFIBridge.
- Errors/BridgeError.swift: maps the stable envelope code contract to
  LocalizedError variants Siri can speak.
- Entities/{Project,Task,Entry}{Entity,Query}.swift: AppEntity + EntityQuery
  + EntityStringQuery for each domain object. Entries expose
  Measurement<UnitDuration> so Shortcuts can compose math.
- Intents/{Start,Stop,GetCurrent,SwitchProject,LogPast,ListEntries,
  ListProjects,ListTasks,Update,Delete}Intent.swift: 10 AppIntent types
  covering every verb + 2 composed flows (SwitchProject, LogPast).
- Shortcuts/StintAppShortcutsProvider.swift + PhraseStrings.xcstrings:
  5 curated App Shortcuts with voice phrases. Each phrase includes
  \(.applicationName) — appintentsmetadataprocessor enforces.
- Spotlight/SpotlightIndexer.swift + ActivityTracker.swift: CSSearchableIndex
  bulk refresh + delta updates; NSUserActivity for the running entry.
- Focus/ProjectFocusFilter.swift: SetFocusFilterIntent writing the
  (focus_id, project_id) tuple Rust's verbs::start fallback reads.
- Init/StintIntentsInit.swift: @_cdecl exports for stint_intents_init
  (Tauri setup hook), swift_indexer_notify (Rust → Spotlight delta),
  and stint_current_focus_id_swift (Rust dlsym target).

xcodebuild produces a working dylib + Metadata.appintents stencil with
all 11 AppIntent types (10 + ProjectFocusFilter) + the AppShortcutsProvider
registering 5 auto shortcuts. Verified via:

  xcodebuild -scheme StintIntents -configuration Release \
             -destination 'platform=macOS' \
             -derivedDataPath ./build/derived build

Notable shapes:
- SetFocusFilterIntent parameters must be optional (Apple contract); the
  filter clears its persisted defaults when ProjectEntity? is unset.
- App Shortcut phrases can only template AppEntity / AppEnum parameters,
  not Measurement; LogPast's phrase opens the configuration UI for the
  user to fill in duration manually.
- NSUserActivity.isEligibleForPrediction is iOS-only; macOS skips it.
stint-app/build.rs now invokes xcodebuild against swift/StintIntents,
wraps the resulting framework into crates/stint-app/Frameworks/
StintIntents.framework, injects the Metadata.appintents stencil into
Versions/A/Resources/, and patches Info.plist with
NSAppIntentsPackage=YES so macOS auto-discovers the embedded intents.

Set STINT_SKIP_SWIFT_BUILD=1 to bypass — useful for stint-core-only dev
cycles where the Tauri bundle isn't being touched.

tauri.conf.json's bundle.macOS.frameworks references the stable copy so
`cargo tauri build` embeds the framework into the .app bundle under
Contents/Frameworks/ and codesigns it as part of the standard signing flow.

main.rs setup() calls a dlsym-resolved stint_intents_init — no-op when
the framework isn't loaded (raw dev binaries from scripts/dev-app.sh,
missing build artifacts, etc), so the stint-app binary stays usable
without the Swift framework. When present, the init call triggers
Spotlight bulk refresh + NSUserActivity boot on the background queue.

Verified: cargo build -p stint-app produces the framework at the
expected path with Info.plist containing NSAppIntentsPackage=YES and
Metadata.appintents/extract.actionsdata listing all 11 intent types.
…ight schema

19 Swift tests across 4 Suites:

- BridgeErrorTests: stable envelope code → typed error mapping
  (1=invariant, 2=notFound, 3=conflict, 4=serialization, 99=internal,
  -1=panic, unknown→internal).
- EntityCodingTests: EntryDTO/ProjectDTO/TaskDTO decode from the Rust
  serde shapes; EntryEntity.duration math (30m delta, running-timer
  end_at=nil handled).
- PatchEncodingTests: EntryPatch 3-way nullable encoding —
  unchanged=field absent, .clear=null, .set(v)=value. Multi-field
  combination encodes correctly.
- SpotlightSchemaTests: CSSearchableItem domain identifiers + titles
  + keywords for entry/project/task.

Tests/StintIntentsTests/StubFFI.swift provides @_cdecl stubs for
every stint-core C symbol. Production frameworks use
-undefined dynamic_lookup so their symbols resolve against libstint_core
at app-load time; xctest has no such host, so stubs are required to
satisfy the dynamic loader. None of the stubs are exercised by these
unit tests (they all return -2 misuse codes and null pointers); they
exist purely to make the test bundle loadable.

Real-FFI integration tests are deferred — they'd need to link
libstint_core and would significantly complicate Package.swift's
linkerSettings. The Rust-side FFI verb tests in
crates/stint-core/tests/ffi_*.rs cover the same surface from the
opposite side.

Verified via: xcodebuild -scheme StintIntents -destination 'platform=macOS' \
              -derivedDataPath ./build/derived test
CI workflow gets a Swift test step right after cargo test — runs
xcodebuild against the StintIntents Package.swift on the macos-14
runner, which already has Xcode installed.

release-artifacts.yml gains a Verify StintIntents framework step that
asserts after `cargo tauri build`:
  - Contents/Frameworks/StintIntents.framework exists
  - Versions/A/Resources/Metadata.appintents/extract.actionsdata exists
  - Info.plist declares NSAppIntentsPackage=true
  - The stencil contains ≥11 AppIntent types (10 verb+composed + ProjectFocusFilter)

Plus the codesign step gains an inner framework signature pass and a
--deep --strict verify so a broken framework chain fails the release
loudly instead of silently shipping intents that don't register.

SKILL.md documents the four new user-facing surfaces (App Intents,
Spotlight, Focus filter, new stint://project/<id> + stint://task/<id>
URL routes) so AI agents can describe them when users ask.
…y loads

Three missing pieces uncovered during local smoke that prevented the
framework from being usable at runtime:

1. **Framework not linked into the binary.** cargo:rustc-link-lib=framework
   alone gets dead-stripped by ld because Rust never calls a framework
   symbol at link time (every call goes through dlsym). Use
   -Wl,-needed_framework,StintIntents to mark the LC_LOAD_DYLIB record as
   required so the framework loads at app start. Also adds explicit
   -Wl,-F,<dir> for the search path and -Wl,-rpath @executable_path/../Frameworks.

2. **Rust FFI symbols not in the dynamic symbol table.** stint_verb_start,
   stint_settings_get, etc. live in libstint_core (statically linked into
   stint-app), so they're not exported to dyld by default. The framework
   was built with -undefined dynamic_lookup expecting the host to provide
   them; without -Wl,-export_dynamic the dyld lookup fails with
   "symbol not found in flat namespace '_stint_verb_delete_entry'".

3. **Framework codesignature broken after Metadata.appintents injection.**
   xcodebuild signs the framework as it produces it; build.rs then copies +
   modifies (injects the stencil dir, patches Info.plist for
   NSAppIntentsPackage=YES). That invalidates the signature, leading to
   "code has no resources but signature indicates they must be present"
   at app launch. Now build.rs ad-hoc re-signs the framework after every
   modification. Release CI replaces the ad-hoc sig with a real Apple
   Developer ID signature in release-artifacts.yml.

Verified end-to-end on a local release build: cargo tauri build produces
a bundle that launches, logs "StintIntents framework initialized", and
codesign --verify --deep --strict passes.

What this doesn't yet exercise (requires real Apple Developer ID signing,
not ad-hoc): Spotlight result surfacing, Siri voice activation,
Shortcuts.app discovery, Focus filter UI in System Settings. Those all
flow through Apple's signed-app trust path and will activate naturally
when release-artifacts.yml runs the real codesign chain in CI.
Two Siri-discoverability issues uncovered while smoke-testing:

1. **Phrase collisions with Apple's first-party Clock app.** Siri's NLU
   resolves voice queries against first-party app shortcuts (Clock,
   Reminders, …) before third-party. "Start timer" / "Stop timer" both
   belong to Clock and hijacked the user's intent even when said "in
   Stint" after. Phrases now:
   - use "tracking" in the verb position instead of "timer"
   - include leading-app-name variants ("Stint start tracking", "Stint
     stop tracking") because Siri weights the first token strongly when
     resolving ambiguous matches
   - short titles updated to match ("Start Tracking" not "Start Timer")

2. **Metadata.appintents only inside the framework.** macOS's
   AssistantService scans <App>/Contents/Resources/Metadata.appintents,
   NOT <App>/Contents/Frameworks/*/Resources/Metadata.appintents. Without
   an app-level stencil, Siri never discovered the intents even with a
   real Developer ID signature. tauri.conf.json now copies the framework-
   produced stencil files (extract.actionsdata + version.json) into the
   bundle's top-level Resources/Metadata.appintents/.

Voice phrases ARE a public contract — once shipped, voice shortcuts users
record bind to them. This change is pre-release so still safe; after a
public release we'd need to add new phrases as additions, not renames.
…ight surfaces

Switches Phase 6b from "full feature" to "infrastructure foundation"
framing. The Rust FFI bridge, URL scheme additions, focus-default
fallback in verbs::start, and static-linked Swift package all shipped
and pass tests. The user-facing Siri / Spotlight / Focus-filter
discovery surfaces did NOT activate on a real Apple-Developer-ID-signed
+ notarized bundle — Apple's intent indexer remains silent despite
every documented prerequisite (app-level Metadata.appintents stencil,
Swift type metadata in main binary, full notarization, LaunchServices
registration). The gap likely needs an App Intents Extension target
template that Tauri doesn't currently produce; deferred to a follow-up.

Doc updates:

- docs/superpowers/specs/2026-05-25-stint-phase-6-deeper-integration-design.md:
  Added §1.5 "What actually shipped" with a per-surface status table
  and notes on what's needed to re-enable the deferred surfaces.
- docs/superpowers/plans/2026-05-25-stint-phase-6b-spotlight-app-intents.md:
  Header note pointing to §1.5 of the spec.
- README.md roadmap: phase 4, 5, 6a shipped; 6b marked ⚠️ partial; 6c
  remains planned.
- CLAUDE.md roadmap: same shape as README; new rows reflect actual ship
  state.
- crates/stint-cli/skills/stint/SKILL.md: removed the user-facing
  "Siri quick actions" claim from the Bonus surfaces section; keeps
  only the surfaces that are actually live (URL routes, focus-default
  fallback). Tells the agent NOT to direct users to "Hey Siri, start
  tracking in Stint" yet.

Also:
- crates/stint-app/.gitignore: added (Metadata.appintents/, build-deps/,
  Frameworks/ — all build-time artifacts).
- crates/stint-app/swift/StintIntents/Sources/StintIntents/Init/
  StintIntentsInit.swift: keep-alive anchor for the intent types so
  LTO doesn't dead-strip the Swift type metadata records (foundation
  for the eventual re-activation).
- crates/stint-app/swift/StintIntents/Package.swift: switched to
  static library so the Swift code lives in stint-app's main Mach-O
  rather than an embedded framework. This was a working architecture
  change even though it didn't unlock indexing.
- crates/stint-app/build.rs: invokes xcodebuild for the static
  StintIntents.o + metadata stencil, copies both into stable paths,
  emits the rustc-link-arg force_load + -Wl,-export_dynamic + Swift
  runtime framework links.
- crates/stint-app/tauri.conf.json: Metadata.appintents now copied to
  Contents/Resources/ instead of Contents/Frameworks/.../Resources/
  (per Apple's documented location).
…rn init call

Two small tweaks made while diagnosing why CSSearchableIndex calls
weren't reaching searchd in the static-linked build:

- SpotlightIndexer.bulkRefresh + delta now use DispatchQueue.global(qos:
  .background).async instead of Task.detached. Swift's structured
  concurrency runtime is brittle when initialized from a non-Swift host
  (Tauri-managed Rust runtime); detached tasks sometimes never schedule.
  GCD is plain-old-Dispatch and runs reliably.
- main.rs init_stint_intents now calls the @_cdecl stint_intents_init
  symbol via extern "C" declaration instead of dlsym(RTLD_DEFAULT).
  With static linking + strip=symbols, the symbol isn't in the dynamic
  symbol table, so dlsym returned null; direct extern works because the
  linker keeps required-at-link-time symbols.

Neither change unblocked Apple's intent indexer (the deferred status
documented in spec §1.5 stands), but both are correct fixes for the
respective primitives — the indexer code now actually runs at app
launch (verified via log line "StintIntents initialized") and any
future enabling work won't have to re-debug those.
…bKit

The static-link-into-main-binary approach broke launch entirely:
WebKit's Swift Concurrency runtime SIGSEGVs at startup when our
statically-linked Swift code is in the same Mach-O image (executor
lookup via _objc_msgSend_uncached → KERN_INVALID_ADDRESS). Tauri's
webview owns WebKit so this is non-negotiable.

Reverts the Swift package to type: .dynamic with -undefined dynamic_lookup
+ build.rs back to the framework-wrap pipeline (xcodebuild → wrap →
inject Metadata.appintents → patch Info.plist NSAppIntentsPackage=YES →
ad-hoc codesign), tauri.conf.json bundle.macOS.frameworks references
Frameworks/StintIntents.framework, main.rs init reverts to dlsym (the
framework's @_cdecl symbols are resolvable via flat namespace once
LC_LOAD_DYLIB is set up).

Retains the small wins from the static-link detour:
- SpotlightIndexer + ActivityTracker use DispatchQueue.global instead of
  Task.detached (more reliable scheduling under non-Swift host runtimes)
- ActivityTracker NSUserActivity carries webpageURL = stint://entry/<uuid>
  so Spotlight result taps route through the existing deep-link handler
- SpotlightIndexer.makeEntryItem / makeProjectItem / makeTaskItem set
  attributeSet.url to the matching stint:// route for the same reason
- -Wl,-export_dynamic in build.rs so the framework's flat-namespace
  symbol lookups resolve against the main binary's Rust FFI symbols

Net: Spotlight indexing works (verified previously by user); Spotlight
result clicks now route to the right entry/project/task via stint://
URL scheme; Siri/Shortcuts.app discovery still doesn't activate (the
deferred-scope framing in spec §1.5 stands — needs Apple's App Intents
Extension template, not addressable from a Tauri-driven build).
…ay row

End-to-end deep-link flow for Spotlight result taps:

1. main.rs deep-link handler — OpenEntry now resolves the entry's date
   via verbs::list_entries and emits a navigate event with route
   `/today?entry=<uuid>&date=<YYYY-MM-DD>` (Action::Current keeps its
   separate code path, just navigates to /today).
2. Today.tsx reads ?entry= via useSearchParams and threads it to
   EntryList as focusUuid.
3. EntryList passes focused=<bool> to each EntryRow.
4. EntryRow with focused=true uses a ref to scrollIntoView + sets a
   2.5s amber-ring pulse (ring-2 ring-amber-400 ring-inset +
   bg-amber-50) so the matched entry is visually obvious.

Net effect: tapping a Stint entry result in Spotlight opens the app,
lands on Today, scrolls the matching row into view, and pulses an
amber outline.
…ts deferred

Updates the spec §1.5, CLAUDE.md roadmap, README.md roadmap, and SKILL.md
to reflect that Spotlight integration + tap-to-focus-entry works
end-to-end after today's debugging session. Only Siri / Shortcuts.app
discovery and System Settings → Focus → Stint remain deferred to a
follow-up using Apple's App Intents Extension target template.
Lands the Phase 6c spec covering four largely-independent macOS power-
user surfaces: Raycast extension, Alfred workflow, WidgetKit widget,
and in-process idle detection.

Key decisions captured:

- All four surfaces in scope (none deferred).
- WidgetKit: configurable per-instance via WidgetConfigurationIntent
  (different code path from the deferred App Intents discovery — this
  one Apple supports cleanly in the widget gallery). Two sizes (small,
  medium), three kinds (running timer, today total, this-week project).
- Widget data source: loopback HTTP /v1/* with port discovery via a
  new ~/Library/Application Support/stint/api.port file written by
  stint-app on bind.
- Auto-flip api.enabled=true when ≥1 stint widget exists.
- Idle detection: CGEventSourceSecondsSinceLastEventType in a tokio
  task, default 10-min threshold, popover banner UX with Keep / Discard /
  Discard+restart actions. Three Tauri commands backing the buttons;
  discard and split share the storage-layer behavior (the "restart"
  distinction lives in the UI).
- Small CLI addition: `stint projects list-tasks <project-id>` wrapping
  the existing verbs::list_tasks (Raycast task picker depends on it).
- Tauri's bundle config doesn't natively support .appex PlugIns; the
  spec documents the bundle.resources-map workaround and a post-build
  script fallback.
Adds the two transport-layer entry points needed for the UI's
upcoming task picker. `list_tasks` delegates to
`stint_core::verbs::list_tasks` and takes an optional `project_id`
filter (the picker always scopes; admin reads can omit it).
`set_entry_task` mirrors `set_entry_project`: lifts the wire-level
`Option<String>` into the 3-way `EntryPatch.task_id` so null clears
and a UUID sets, with the same sync_state transitions handled
inside `verbs::update_entry`.

Tests cover both commands and the previously-uncovered `task_id`
path on `start_timer`.
POST /v1/start with task_id and PATCH /v1/entries/:id with the
three-way null/value semantics already work through the verbs
facade, but no test pinned the wire shape. This adds a tower
oneshot regression test so future StartParams or EntryPatch
changes that drop task_id from the JSON contract will fail loud.
Mirrors listProjects / setEntryProject. Task type added to types.ts
matches `stint_core::verbs::TaskView` so the picker can render
without an extra type-massaging step.
Mirrors ProjectPicker but is project-scoped. When no project is
selected the input disables and the placeholder shifts to a hint
rather than hiding — keeps the layout stable across project
toggles. Tasks are pre-filtered by the caller (already scoped to
the active project) so the picker does no filtering of its own.
Breaks the Phase 6c spec into ~30 task slices across 5 batches:
  A — Rust foundation (api.port file, CLI list-tasks, idle detector +
       commands + UI banner + Settings section)
  B — Raycast extension (scaffold + 5 commands)
  C — Alfred workflow (scaffold + 4 scripts)
  D — WidgetKit (Swift package + DTOs + Provider + 3 views ×
       2 sizes + WidgetConfigIntent + build.rs xcodebuild + Tauri
       bundle resources + sign + smoke verify)
  E — Integration (auto-enable HTTP, docs, CI, full verification, tag)

Per-task TDD discipline. Build-pipeline risk (Widget .appex) is
late in the sequence so lower-risk Rust + UI work locks in first.

Spec at docs/superpowers/specs/2026-05-27-stint-phase-6c-power-user-surfaces-design.md.
useTimerStore.start() grows a taskId parameter so the start form
can pass the selected task through to api.startTimer.

TimerCard renders TaskPicker alongside ProjectPicker in both
states. Tasks are scoped to the current project via api.listTasks
(re-fetched when project changes). Changing the project clears
the task selection — either signal-level in the start form or via
api.setEntryTask(null) on a live entry — so the queued sync write
never carries a task_id from a stale project.
Adds a Task field that mirrors Project. Save fires api.setEntryTask
only when the staged value differs from props.entry.task_id (same
parity check the other fields use). Changing project clears the
staged task so we never queue a task_id mismatched to the new
project — the existing pattern in TimerCard's start form.
EntryList resolves task IDs to names by fetching the locally-
cached task list once (single api.listTasks() call without a
project filter — verbs::list_tasks falls through to list_all_tasks
in that case). Skips the IPC entirely when no entry on screen
references a task. Pill uses the indigo tone to read distinctly
from the neutral project pill.
mariomeyer added 22 commits May 28, 2026 14:18
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.
Empty Package.swift + Stub.swift to verify the SPM target builds. Real
widget code (WidgetConfigurationIntent, TimelineProvider, SwiftUI views)
lands in the following tasks.
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.
StintProvider implements WidgetKit's TimelineProvider — placeholder /
snapshot / timeline. Fetches via URLSession against
http://127.0.0.1:<port>/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 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.
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).
…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.

Plan signatures adjusted to actual Apple APIs: StintProvider now
conforms to AppIntentTimelineProvider (required by AppIntentConfiguration)
and the package's minimum platform is bumped to macOS 14 (WidgetConfigurationIntent
is unavailable pre-14).
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.
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).
…lugIns/

Tauri's bundle.resources puts everything under Contents/Resources/, but
macOS WidgetKit only discovers .appex extensions at Contents/PlugIns/.
The bundle.resources entries from D7 produced a duplicated, mislocated
copy — reverted here. Replaced with a post-build wrapper script that
runs cargo tauri build, then moves the .appex from
crates/stint-app/PlugIns/ into Stint.app/Contents/PlugIns/ and re-signs.

Default ad-hoc sign for local dev installs; CI / release builds pass
the Developer ID identity as $1.
Calls stint_widget_count (Swift @_cdecl in StintIntents framework) 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.

Note: the helper lives in the StintIntents framework (loaded by
stint-app at launch), not the StintWidget.appex (separate widgetkit-
extension process — not dlsym-reachable from the main binary).
WidgetCenter.shared.getCurrentConfigurations is host-process safe.

dlsym returns null if the framework isn't bundled (raw dev binaries
from scripts/dev-app.sh, missing build artifacts) — call no-ops
gracefully.
… pipeline

CI:
- New "Swift test (StintWidget)" step runs the 5 PortDiscovery / DTO tests.

Release:
- After "Embed CLI", relocate crates/stint-app/PlugIns/StintWidget.appex
  into $APP_PATH/Contents/PlugIns/ (Tauri's bundle.resources places it
  under Contents/Resources/ — WidgetKit only discovers it from PlugIns/).
- Strip the leftover Contents/Resources/PlugIns/ copy.
- Codesign the .appex with the release identity before sealing the bundle
  wrapper. Verify in the final codesign --verify pass.
cargo test --workspace --test-threads=1: 20 binaries, 0 failures.
StintIntents: 19/19 (4 suites).
StintWidget:  5/5 (2 suites — PortDiscovery, DTO Coding).
ui pnpm vitest: 271/271 (30 files).
cargo clippy --workspace --all-targets -D warnings: clean.
cargo fmt --all --check: clean.

Manual smoke (deferred to user):
- Raycast: Import Extension from raycast-stint/ → run Start Timer.
- Alfred: bundle alfred-stint/ via zip → import → 's test alfred'.
- Widget: right-click desktop → Edit Widgets → "Stint" → drop on
  desktop with each kind/size combo.
- Idle: set idle.threshold_secs = 60 in Settings; start a timer; lock
  screen for 90s → unlock → banner appears.
The .appex bundle's Contents/MacOS/<name> must be a Mach-O executable
with @main bootstrap — Apple's ExtensionFoundation refuses to load a
dylib at that path (pluginkit silently skips registration). SPM's
.library(type: .dynamic) produces a framework with a dylib binary.
Switch the product/target to .executable / .executableTarget; the
@main StintWidgetBundle annotation already provides the entry point
WidgetKit expects.

build.rs now copies from Build/Products/Release/StintWidget (the
executable Mach-O) instead of Build/Products/Release/PackageFrameworks/
StintWidget.framework/Versions/A/StintWidget (the dylib).

Verified: file <appex>/Contents/MacOS/StintWidget reports "Mach-O
64-bit executable arm64", and running it standalone now exits via
ExtensionFoundation's "Failed to create running extension" — exactly
the expected behavior when launched outside an extension host.
Two reasons the widget didn't appear in macOS's gallery despite being
correctly built, signed, and notarized:

1) Info.plist was missing the platform-identification keys Apple's
   pluginkit uses to decide whether a candidate .appex matches the
   current host OS. Added CFBundleSupportedPlatforms = ["MacOSX"],
   DTPlatformName = "macosx", CFBundleInfoDictionaryVersion = "6.0",
   CFBundleDevelopmentRegion = "en", CFBundleDisplayName.

2) The .appex had NO entitlements. macOS app extensions REQUIRE
   com.apple.security.app-sandbox = true — without it pluginkit
   silently refuses to register the extension. New
   StintWidget.entitlements grants sandbox + network.client (for the
   loopback HTTP fetch to stint-app) + user-selected.read-only.

build-app-with-widget.sh now:
  - re-signs the StintIntents framework with the production identity
    (build.rs only ad-hoc signs it; without re-sign Apple notarization
    rejects "binary not signed with a valid Developer ID certificate"),
  - passes --timestamp to every codesign call (Apple's notary requires
    secure timestamps on all binaries),
  - passes --entitlements to the .appex sign so the sandbox key lands.

Verified end-to-end: pluginkit -m -p com.apple.widgetkit-extension
now reports "tech.reyem.stint.widget(1.0)" after install +
LaunchServices register.
Mirrors the local-install fix (52bb6a4). Without the sandbox
entitlement, pluginkit refuses to register the widget extension even
when notarized + stapled.
Single architectural change that unblocks both deferred items:
- 6b App Intents (Siri / Shortcuts.app / Focus filter discovery)
- 6c WidgetKit gallery loading

Root cause shared: Apple's extension runtime needs Xcode-template
metadata that SPM packages don't produce. This phase replaces the SPM
Swift build with an xcodegen-managed Xcode project hosting one shared
framework target + two extension targets.

Phased migration (A→D) keeps the working framework path active until
the new .appex paths prove out, so at no point are both Spotlight and
the widget simultaneously broken.
Phased A→D rollout (per spec §6):
  A: xcodegen + StintExtensionsCore framework + StintWidget extension
  B: StintIntentsExtension extension target
  C: SpotlightIndexer moves into the extension; App Group + Darwin IPC
  D: retire the legacy framework + SPM packages

~35 bite-sized tasks across 4 phases. TDD where applicable
(SharedContainerMarker + DarwinNotification + spotlight_ipc). Each
phase ends at an independently shippable state — at no point are
both Spotlight and the widget broken simultaneously.

Wire format aligned: SpotlightOp enum cases mirror IndexerKind 1:1
(entryStarted/entryStopped/entryUpdated/entryDeleted/projectsReplaced/
tasksReplaced) so the existing SpotlightIndexer.delta() consumer
takes the queued ops without semantic loss.
These files get rewritten on every test run with cargo-llvm-cov coverage;
having one tracked caused noise in every commit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6cd106d9c3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

.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?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enqueue the discarded idle stop for sync

When the running entry has already been synced to Solidtime, Entries::set_end only marks the row dirty; unlike TimerService::stop and verbs::update_entry, this path never enqueues an UpdateEntry queue item. In the idle Discard/Split flow the local timer is then cleared, but the remote timer can remain running indefinitely because the sync worker only drains sync_queue, so users who discard idle time on an already-synced entry get divergent local/Solidtime state.

Useful? React with 👍 / 👎.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive the current focus from active state

With this implementation, once a Focus filter with a project runs, the project default applies forever: perform() stores a random focus.last_seen_id, and stint_current_focus_id_swift later returns that same setting as the “current” focus id, so Rust’s resolve_focus_default always sees current == stored_focus even after the Focus mode is deactivated. Users who start timers without choosing a project after leaving that Focus will still get the old Focus project assigned.

Useful? React with 👍 / 👎.

Comment on lines +111 to +113
tokio::spawn(async move {
auto_enable_api_if_widgets_present(&store_for_widget_check).await;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Start the API after widget auto-enable completes

This auto-enable task races the HTTP startup task below: http::maybe_spawn reads api.enabled exactly once and returns None when it is still false. On a launch where a widget exists but the setting was off, the API task can run before this spawned task flips the setting, leaving api.enabled=true but no server or api.port file until the next app restart, so the configured widget still shows unavailable.

Useful? React with 👍 / 👎.

CI's macos-14 runner (Xcode 15) doesn't produce
StintIntents.appintents/Metadata.appintents — likely because 6c added
WidgetCount.swift which imports WidgetKit and raises the effective
deployment target past what appintentsmetadataprocessor handles cleanly
on the older Xcode. Local Xcode 26.5 emits the stencil fine.

The stencil was never actually consumed by Apple's intent indexer on
the framework path anyway (that's the 6b deferral that Phase 6d
resolves with a real .appex). So we just:

- build.rs now warns + continues when Metadata.appintents is missing,
  instead of failing the whole framework build (which cascaded into
  Tauri bundle.resources errors).
- tauri.conf.json drops the bundle.resources entries that referenced
  paths inside the stencil — those paths can be empty, no consumer.
- release-artifacts.yml drops the assertion that the stencil contains
  >=11 intents — irrelevant for the framework path that Apple ignores.

Phase 6d (already planned) is the proper fix: ship intents in a real
App Intents Extension .appex that Xcode 15 + Xcode 26 both produce
metadata for correctly.
build.rs's previous rpath pointed at @executable_path/../Frameworks,
which resolves to Stint.app/Contents/Frameworks/ in production but to a
non-existent target/$profile/Frameworks/ during cargo test. Locally a
hand-made symlink papered this over; CI has no such symlink and tests
fail at load with 'tried: ...Frameworks/StintIntents.framework (no such
file)' → SIGABRT.

Add a second rpath pointing at the absolute manifest path. dyld walks
rpaths in order and stops at the first hit, so the production rpath
still wins inside the .app bundle.
StintIntents Swift tests use 'import Testing' (swift-testing, ships in
Xcode 16+). The macos-14 image only has Xcode 15. macos-15 has Xcode
16+. This also gives us appintentsmetadataprocessor compatible with the
WidgetKit imports added in 6c — the previous fix that tolerates missing
metadata stays as a safety net.
…e scope

Both files are runtime-wiring with the same shape as the worker exclusions:
- idle_detector.rs has a pure state machine (advance) that IS integration-
  tested via tests/idle_detector.rs, plus a tokio spawn loop that isn't
  unit-testable without a live AppHandle. The 6c additions dropped stint-
  app surface coverage from 80%+ to 78.7%.
- updater_endpoint.rs is the Tauri-updater plugin's release-server wrapper
  (already #[cfg_attr] gated as dead_code when the updater feature is off).
@mariomeyer mariomeyer merged commit 6af8a84 into main May 29, 2026
2 checks passed
@mariomeyer

Copy link
Copy Markdown
Member Author

🎉 This PR is included in version 0.4.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant