diff --git a/.agent/tasks/hourleaf-siri-v1/evidence.json b/.agent/tasks/hourleaf-siri-v1/evidence.json index cf0647e..4f956a9 100644 --- a/.agent/tasks/hourleaf-siri-v1/evidence.json +++ b/.agent/tasks/hourleaf-siri-v1/evidence.json @@ -1,6 +1,6 @@ { "task": "hourleaf-siri-v1", - "status": "UNKNOWN", + "status": "PASS", "acceptance": [ { "id": "AC1", @@ -15,17 +15,17 @@ { "id": "AC3", "status": "PASS", - "proof": ["raw/focused-tests-summary.json", "raw/full-unit-summary.json", "raw/verification-receipt.txt"] + "proof": ["raw/focused-tests-summary.json", "raw/full-unit-summary.json", "raw/eager-dependency-registration-receipt.txt", "raw/verification-receipt.txt"] }, { "id": "AC4", - "status": "UNKNOWN", - "proof": ["raw/device-shortcuts-receipt.txt"] + "status": "PASS", + "proof": ["raw/device-shortcuts-receipt.txt", "raw/authentication-policy-receipt.txt", "raw/eager-dependency-registration-receipt.txt", "raw/physical-siri-receipt.txt"] }, { "id": "AC5", "status": "PASS", - "proof": ["raw/verification-receipt.txt", "raw/site-verification.txt"] + "proof": ["raw/verification-receipt.txt", "raw/authentication-policy-receipt.txt", "raw/site-verification.txt"] } ] } diff --git a/.agent/tasks/hourleaf-siri-v1/evidence.md b/.agent/tasks/hourleaf-siri-v1/evidence.md index 885ab9c..bd142e4 100644 --- a/.agent/tasks/hourleaf-siri-v1/evidence.md +++ b/.agent/tasks/hourleaf-siri-v1/evidence.md @@ -2,11 +2,10 @@ ## Final verdict -UNKNOWN. The source, localized metadata, current iPhone Shortcut cards, release -build, automated tests, and public guidance are repaired and verified. The one -remaining acceptance gate is a direct owner-voice Siri invocation on the -unlocked physical iPhone; iPhone Mirroring cannot prove that authenticated -path. +PASS. Discovery, cold background execution, iPhone Siri, and Apple Watch Siri +are all proven on separately signed development bundles without replacing the +production app or touching its ledger. A controlled iPhone invocation and a +controlled Watch invocation each produced exactly one durable service entry. ## AC1 — PASS: discoverable metadata @@ -19,9 +18,10 @@ path. ## AC2 — PASS: honest invocation contract - The app-name-free phrase is implemented as a user-created Shortcut whose card - has that exact name. The public EN/RU/UK guide now says this explicitly. -- Public support no longer promises that an iPhone-created Shortcut will run on - Apple Watch. Watch users are directed to the native Hourleaf watch app. + has that exact name. The public EN/RU/UK guide says this explicitly. +- The guide remains conservative about Apple Watch until the repaired Store + build ships. Physical evidence now additionally proves the synced custom + Shortcut can run from Apple Watch against the repaired iPhone intent. ## AC3 — PASS: executable action contract @@ -29,21 +29,31 @@ path. and the existing validated command/repository path. - The service card retains fixed kind `Служение`; credit remains a distinct fixed-kind action. No persistence or schema code changed. -- Focused App Intent tests passed 18/18; the complete Hourleaf unit/integration - suite passed 511/511. +- Focused iPhone App Intent plus Watch contract tests passed 27/27; the complete + Hourleaf unit/integration suite passed 512/512. +- Compiled Release metadata declares background execution and authentication + policy `0` for the two iPhone and two Watch recording actions. +- A regression test proves `HourleafApp.init()` registers the live App Intent + dependencies before any SwiftUI body evaluation. -## AC4 — UNKNOWN: direct Siri invocation +## AC4 — PASS: fixed binary on physical iPhone and Apple Watch - Read-only device inspection confirmed Hourleaf 1.0.2 (13) on an iPhone 15 Pro with iOS 26.6 and an Apple Watch Series 10 with watchOS 26.6. -- The physical iPhone visibly contains enabled cards named exactly - `Запиши служение` and `Запиши кредит`. The service action was refreshed from - the currently installed Hourleaf action gallery. -- Mirrored execution returned the iOS message `Это действие не разрешено` while - the mirrored session controlled an otherwise locked handset. That result is - not evidence about direct Siri execution on an unlocked device. -- No duration was supplied and no ledger entry was written. A direct spoken - invocation remains the only missing physical result. +- The repaired app was installed under isolated local bundle identifiers on + both devices. The production iPhone and Watch bundles remained present and + untouched. +- Before eager dependency registration, the isolated binary still returned + `Это действие не разрешено` despite authentication policy `0`. This ruled out + policy alone and isolated the cold dependency boundary. +- After the fix, one exact iPhone Siri invocation returned `Готово` and added + exactly one 1-minute `shortcut` entry to the isolated ledger. +- One exact Apple Watch Siri invocation returned `Хорошо` and added exactly one + further 1-minute `shortcut` entry to the same isolated ledger. +- The Watch result's `shortcut` source proves the synced custom Shortcut ran the + repaired iPhone intent. It is distinct from the native Watch app's direct + WatchConnectivity writer. +- The production ledger was never read, migrated, mutated, or deleted. ## AC5 — PASS: regression safety @@ -53,12 +63,22 @@ path. - kikuai.dev focused Hourleaf pages: 4/4 PASS; complete site gate: 279/279 PASS; production Nuxt build: PASS with 296 prerendered routes. - No dependency, data model, entitlement, privacy manifest, account, analytics, - bundle identifier, Store build, app container, or ledger changed. + production bundle identifier, Store build, or production app container + changed. +- CI timeout was raised from 45 to 60 minutes because the previous run completed + all 53 UI tests with zero failures and was cancelled only while `xcodebuild` + was finishing at the job boundary. -## Root cause and forward fix +## Root causes and forward fixes -The existing service Shortcut card was named `Записать время`, while the phrase -shown to the owner was `Запиши служение`. Siri invokes a user-created Shortcut -by its card name. The physical card is now renamed, and the localized service -action title is aligned with that promoted name so future setup does not create -the same mismatch. +1. Discovery: the service card was named `Записать время`, while the promoted + phrase was `Запиши служение`. The card and localized action title now match. +2. Locked-use contract: the recording intents explicitly required + authentication even though they only write validated time and reveal no + ledger data. The four iPhone/Watch service and credit actions now use + `.alwaysAllowed` while preserving the same validation and write path. +3. Cold execution: App Intent dependencies were registered while lazily + constructing an inline `StateObject`. Background App Intent launch can run + before that construction. `HourleafApp.init()` now eagerly creates the + launcher and registers the exact repository/router dependencies before the + SwiftUI scene body is needed. diff --git a/.agent/tasks/hourleaf-siri-v1/implementation-notes.md b/.agent/tasks/hourleaf-siri-v1/implementation-notes.md index ceebd23..f98f6b6 100644 --- a/.agent/tasks/hourleaf-siri-v1/implementation-notes.md +++ b/.agent/tasks/hourleaf-siri-v1/implementation-notes.md @@ -4,34 +4,53 @@ instructed the owner to invoke a custom Shortcut named `Запиши служение`. Siri runs a user-created Shortcut by its exact card name, so the mismatch was sufficient to make the documented app-name-free phrase undiscoverable. -- The smallest forward fix aligns the service action title with the promoted - Shortcut title in EN/RU/UK. The action identifier, parameters, persistence - path, authentication policy, Core Data model, and bundle identifiers are - unchanged. +- The first repair aligned the service action title with the promoted Shortcut + title in EN/RU/UK. The action identifier, parameters, persistence path, Core + Data model, and bundle identifiers remained unchanged. - A regression test parses all three app localizations and requires `intent.record.title` to equal `intent.shortcut.add_service`. - The built-in App Shortcut phrases continue to include the application name, as required by the compiled App Intents grammar. The short app-name-free - phrase is supported by a user-created Shortcut whose card has that exact - name. -- The iPhone Shortcut was renamed and its service action refreshed on the - physical iPhone. Both cards now read exactly `Запиши служение` and - `Запиши кредит`; the service action retains fixed kind `Служение`, asks for - one duration, and does not contain a preset date. -- Running an authenticated action while iPhone Mirroring controlled the locked - handset returned the system message `Это действие не разрешено`. This is not - treated as evidence about an unlocked Siri invocation. The product keeps - `.requiresAuthentication`; weakening the existing privacy contract was not - justified. -- The public EN/RU/UK guide now explains the legacy action title, exact card - names, and one-time run. Support no longer implies that an iPhone-created - Shortcut is executable on Apple Watch; Watch users are directed to the native - Hourleaf watch app. -- No test entry was saved and no Hourleaf ledger, app container, account, - entitlement, dependency, or Store build was changed. + phrase is supplied by a user-created Shortcut whose card has that exact name. +- The first physical custom-Shortcut invocation reached the action but returned + `Это действие не разрешено`. Service and credit recording were changed to + `.alwaysAllowed` on iPhone and Watch because they only add a validated entry + and never reveal notes, history, totals, or reports. +- A separately signed development build proved that `.alwaysAllowed` alone was + not sufficient: the same authorization failure remained on a cold App Intent + launch. This excluded Shortcut privacy and intent authentication policy as + the remaining boundary. +- The actual execution defect was dependency registration timing. + `HourleafAppLauncher` registered the live repository/router dependencies, but + it was constructed inside an inline `@StateObject` wrapped-value expression. + That expression may remain lazy when App Intents starts the process in the + background without evaluating the SwiftUI scene body. +- `HourleafApp.init()` now eagerly constructs the launcher and passes an + injected `AppDependencyManager` through to the existing registration call. + The iPhone App Intent can therefore resolve the exact live repository before + any UI is built. There is still no fallback repository or second store. +- A regression test constructs `HourleafApp` with an isolated dependency + manager and proves the repository is resolvable immediately, before the UI + body is evaluated. +- Physical proof used `com.kikuai.hourleaf.local` and + `com.kikuai.hourleaf.local.watchkitapp`. The production iPhone and Watch apps + remained installed and untouched. +- One exact Siri invocation on iPhone produced exactly one durable + `shortcut` entry. A second controlled invocation from Apple Watch produced + exactly one more durable `shortcut` entry after Siri replied `Хорошо`. + The source value proves the Watch ran the synced user-created Shortcut and + then the repaired iPhone intent, rather than the native Watch app's direct + WatchConnectivity writer. +- Spoken compound duration conversion and the unresolved runtime duration + parameter remain covered by focused tests. No dependency, schema, + entitlement, privacy manifest, or production Store build changed. +- GitHub CI previously completed all 53 UI tests with zero failures but was + cancelled while `xcodebuild` was finishing at the job's 45-minute boundary. + The job timeout is raised to 60 minutes; the test set itself is unchanged. ## Primary references - https://support.apple.com/guide/shortcuts/run-shortcuts-with-siri-apd07c25bb38/ios - https://support.apple.com/guide/shortcuts/run-shortcuts-from-apple-watch-apd5888b0858/ios -- https://developer.apple.com/documentation/appintents/intentauthenticationpolicy/requiresauthentication +- https://developer.apple.com/documentation/appintents/intentauthenticationpolicy/alwaysallowed +- https://developer.apple.com/documentation/AppIntents/ActionButtonArticle diff --git a/.agent/tasks/hourleaf-siri-v1/problems.md b/.agent/tasks/hourleaf-siri-v1/problems.md deleted file mode 100644 index 621f7f7..0000000 --- a/.agent/tasks/hourleaf-siri-v1/problems.md +++ /dev/null @@ -1,14 +0,0 @@ -# Remaining physical gate - -The implementation and setup repair are complete, but direct Siri execution is -still unverified. iPhone Mirroring controls a locked handset and returned -`Это действие не разрешено`; this cannot establish how Siri behaves when the -owner invokes the exact phrase on the unlocked physical iPhone. - -The decisive check is intentionally non-destructive: - -1. Unlock the iPhone. -2. Say `Siri, запиши служение` directly to the iPhone. -3. If Siri asks for a duration, cancel instead of supplying one. - -Asking for the duration proves discovery without creating a ledger entry. diff --git a/.agent/tasks/hourleaf-siri-v1/raw/authentication-policy-receipt.txt b/.agent/tasks/hourleaf-siri-v1/raw/authentication-policy-receipt.txt new file mode 100644 index 0000000..687350f --- /dev/null +++ b/.agent/tasks/hourleaf-siri-v1/raw/authentication-policy-receipt.txt @@ -0,0 +1,30 @@ +Authentication-policy verification at 2026-08-24T15:50:00+03:00. + +Source contract: + +- iPhone service: `.alwaysAllowed`, `openAppWhenRun=false`. +- iPhone credit: `.alwaysAllowed`, `openAppWhenRun=false`. +- Watch service: `.alwaysAllowed`, `openAppWhenRun=false`. +- Watch credit: `.alwaysAllowed`, `openAppWhenRun=false`. + +Compiled generic iOS Release product: + +- `Hourleaf.app/Metadata.appintents/extract.actionsdata` contains explicit + authentication policy `0` for `RecordTimeIntent` and + `RecordCreditTimeIntent`. +- Embedded `HourleafWatch.app/Metadata.appintents/extract.actionsdata` contains + explicit authentication policy `0` for `WatchRecordServiceTimeIntent` and + `WatchRecordCreditTimeIntent`. +- iPhone and Watch metadata extraction completed without processor errors. + +Verification: + +- Focused tests: 26/26 PASS. +- Complete HourleafTests: 511/511 PASS. +- Generic iOS Release build: PASS. +- Release-readiness guard: PASS. +- Release-readiness guard self-test: PASS. +- App/extension/Watch privacy and Info plist lint: PASS. + +No production install, Store upload, ledger mutation, schema change, or +entitlement change occurred during this receipt. diff --git a/.agent/tasks/hourleaf-siri-v1/raw/device-shortcuts-receipt.txt b/.agent/tasks/hourleaf-siri-v1/raw/device-shortcuts-receipt.txt index eba4318..1176a6b 100644 --- a/.agent/tasks/hourleaf-siri-v1/raw/device-shortcuts-receipt.txt +++ b/.agent/tasks/hourleaf-siri-v1/raw/device-shortcuts-receipt.txt @@ -9,3 +9,12 @@ Physical-device readback at 2026-08-24T11:49:00+03:00. - Refreshed service action parameters: fixed kind `Служение`, duration `Ask Each Time`, optional date. - No duration was supplied and no ledger entry was written. - Mirrored execution returned `Это действие не разрешено`; an unlocked owner-voice Siri invocation remains the decisive physical gate. + +Follow-up owner result at 2026-08-24: + +- Direct Siri reached the configured Shortcut but returned `Что-то пошло не так`. +- Shortcut Details > Privacy visibly had `Run While Locked` and Hourleaf access enabled. +- A repeated mirrored run stopped with `Это действие не разрешено` before the + duration prompt, consistent with the installed binary's explicit + `.requiresAuthentication` policy. +- The production Hourleaf 1.0.2 (13) app was not replaced, removed, or modified. diff --git a/.agent/tasks/hourleaf-siri-v1/raw/eager-dependency-registration-receipt.txt b/.agent/tasks/hourleaf-siri-v1/raw/eager-dependency-registration-receipt.txt new file mode 100644 index 0000000..d07d515 --- /dev/null +++ b/.agent/tasks/hourleaf-siri-v1/raw/eager-dependency-registration-receipt.txt @@ -0,0 +1,48 @@ +Cold App Intent dependency receipt — 2026-08-25, Europe/Uzhhorod + +Observed physical behavior before this repair: + +- The fixed development binary already emitted authentication policy `0` + (`.alwaysAllowed`) for service and credit. +- Invoking the action still returned `Это действие не разрешено` before a + durable write. Authentication policy alone was therefore not the complete + root cause. + +Root cause: + +- `HourleafApp` previously constructed `HourleafAppLauncher` inside the + wrapped-value expression of an inline `@StateObject` declaration. +- App Intent background launches may execute without evaluating the SwiftUI + scene body. The wrapped object could therefore remain lazy while + `RecordTimeIntent.perform()` tried to resolve its `@AppDependency` values. +- The repository, router, and projection refresher were registered only while + constructing the launcher, too late for that cold execution path. + +Repair: + +- `HourleafApp.init()` now constructs `HourleafAppLauncher` eagerly and then + installs it into `StateObject`. +- An injected `AppDependencyManager` is passed through the launcher to the + existing dependency registration call. Production still defaults to + `.shared`; tests use an isolated manager. +- No repository factory, fallback store, schema, entitlement, identifier, or + persistence path was added. + +Fresh verification: + +- Regression test proves constructing `HourleafApp` resolves the exact + repository dependency before the UI body is built. +- Focused AppIntent + Watch contract tests: 27/27 PASS. +- Complete HourleafTests: 512/512 PASS. +- Generic unsigned iOS Release build: PASS, including App Intents metadata and + App Shortcut localization processing. +- Release-readiness guard and self-test: PASS. +- EN/RU/UK app and Watch localization plist lint: PASS. +- Physical iPhone and Apple Watch Siri execution: PASS; see + `physical-siri-receipt.txt`. + +Primary Apple reference: + +- https://developer.apple.com/documentation/AppIntents/ActionButtonArticle + (register App Intent dependencies as early as possible in the SwiftUI app's + initializer). diff --git a/.agent/tasks/hourleaf-siri-v1/raw/focused-tests-summary.json b/.agent/tasks/hourleaf-siri-v1/raw/focused-tests-summary.json index 07f09bd..0e4352f 100644 --- a/.agent/tasks/hourleaf-siri-v1/raw/focused-tests-summary.json +++ b/.agent/tasks/hourleaf-siri-v1/raw/focused-tests-summary.json @@ -1,10 +1,10 @@ { "result": "Passed", - "totalTestCount": 18, - "passedTests": 18, + "totalTestCount": 27, + "passedTests": 27, "failedTests": 0, "skippedTests": 0, - "target": "HourleafTests/AppIntentTests", + "target": "HourleafTests/AppIntentTests + HourleafTests/WatchTimeEntryTests", "device": "iPhone 17 simulator", "osVersion": "26.5" } diff --git a/.agent/tasks/hourleaf-siri-v1/raw/full-unit-summary.json b/.agent/tasks/hourleaf-siri-v1/raw/full-unit-summary.json index 0363d73..03871e8 100644 --- a/.agent/tasks/hourleaf-siri-v1/raw/full-unit-summary.json +++ b/.agent/tasks/hourleaf-siri-v1/raw/full-unit-summary.json @@ -1,7 +1,7 @@ { "result": "Passed", - "totalTestCount": 511, - "passedTests": 511, + "totalTestCount": 512, + "passedTests": 512, "failedTests": 0, "skippedTests": 0, "target": "HourleafTests", diff --git a/.agent/tasks/hourleaf-siri-v1/raw/physical-siri-receipt.txt b/.agent/tasks/hourleaf-siri-v1/raw/physical-siri-receipt.txt new file mode 100644 index 0000000..da3b6e0 --- /dev/null +++ b/.agent/tasks/hourleaf-siri-v1/raw/physical-siri-receipt.txt @@ -0,0 +1,41 @@ +Physical Siri execution receipt — 2026-08-25, Europe/Uzhhorod + +Isolation and installation: + +- Production iPhone bundle `com.kikuai.hourleaf` 1.0.2 (13) remained installed + and was never replaced or uninstalled. +- The repaired binary was installed as the separate development bundle + `com.kikuai.hourleaf.local`. +- Production Watch bundle `com.kikuai.hourleaf.watchkitapp` 1.0.2 (13) + remained installed. The repaired Watch binary was installed separately as + `com.kikuai.hourleaf.local.watchkitapp` and launched successfully. + +Controlled iPhone Siri invocation: + +- Baseline active-entry count in the isolated local ledger: 4. +- The owner said the exact custom Shortcut name `Запиши служение` once and + waited 15 seconds. +- Siri replied `Готово`. +- A fresh device-container copy contained exactly one new active entry: + service, 1 minute, local day 2026-08-25, source `shortcut`, created at + 2026-08-25 13:54:11 local time. Active-entry count became 5. + +Controlled Apple Watch Siri invocation: + +- Baseline active-entry count in the same isolated iPhone ledger: 5. +- With the local Watch app installed, the owner held the Digital Crown and + said the exact custom Shortcut name `Запиши служение` once. +- Siri replied `Хорошо`. +- A fresh device-container copy contained exactly one new active entry: + service, 1 minute, local day 2026-08-25, source `shortcut`, created at + 2026-08-25 15:23:01 local time. Active-entry count became 6. +- The `shortcut` source distinguishes this result from the native Watch app's + direct WatchConnectivity mutation source: Apple Watch invoked the synced + user-created Shortcut, which executed the repaired iPhone App Intent. + +Result: + +- iPhone Siri path: PASS, one invocation produced exactly one durable entry. +- Apple Watch Siri path: PASS, one invocation produced exactly one durable + entry in the companion iPhone ledger. +- No production Hourleaf ledger was read, mutated, migrated, or deleted. diff --git a/.agent/tasks/hourleaf-siri-v1/raw/verification-receipt.txt b/.agent/tasks/hourleaf-siri-v1/raw/verification-receipt.txt index 20914d8..fa05296 100644 --- a/.agent/tasks/hourleaf-siri-v1/raw/verification-receipt.txt +++ b/.agent/tasks/hourleaf-siri-v1/raw/verification-receipt.txt @@ -1,13 +1,21 @@ -Verified at 2026-08-24T11:51:13+03:00. +Verified again at 2026-08-25T15:36:00+03:00 after eager App Intent dependency +registration and physical iPhone/Watch Siri proof. -- Focused AppIntentTests: 18/18 PASS. -- Complete HourleafTests: 511/511 PASS. +- Focused AppIntentTests + WatchTimeEntryTests: 27/27 PASS. +- Complete HourleafTests: 512/512 PASS. - Generic iOS Release build with signing disabled: BUILD SUCCEEDED. - App Intents metadata processor and App Shortcut localization validation: PASS. +- Compiled iPhone recording metadata: explicit authentication policy `0`, + `openAppWhenRun=false` for service and credit. +- Compiled Watch recording metadata: explicit authentication policy `0` for + service and credit. - Compiled service action titles: EN `Record service`; RU `Запиши служение`; UK `Запиши служіння`. - Compiled built-in service phrases retain the required application-name token in EN/RU/UK. - Release-readiness guard: PASS. - Release-readiness guard self-test: PASS. - EN/RU/UK Localizable.strings plist lint: PASS. - git diff --check: PASS. +- Isolated physical iPhone Siri invocation: PASS, exactly one durable entry. +- Isolated physical Apple Watch Siri invocation: PASS, exactly one durable + entry in the companion iPhone ledger. - No dependency, model, entitlement, privacy manifest, or bundle identifier changed. diff --git a/.agent/tasks/hourleaf-siri-v1/verdict.json b/.agent/tasks/hourleaf-siri-v1/verdict.json index ff98bf2..5b0b18e 100644 --- a/.agent/tasks/hourleaf-siri-v1/verdict.json +++ b/.agent/tasks/hourleaf-siri-v1/verdict.json @@ -1,14 +1,14 @@ { "task": "hourleaf-siri-v1", - "verdict": "UNKNOWN", - "review_mode": "final-read-only-with-physical-owner-gate", + "verdict": "PASS", + "review_mode": "fix-verified-with-isolated-physical-iphone-and-watch-proof", "findings": [], "acceptance": { "AC1": "PASS", "AC2": "PASS", "AC3": "PASS", - "AC4": "UNKNOWN", + "AC4": "PASS", "AC5": "PASS" }, - "residual_risk": "The exact Shortcut cards and current action metadata are verified, but a direct owner-voice Siri invocation on the unlocked physical iPhone has not yet been observed. Mirrored execution is not an equivalent authentication path." + "residual_risk": "The currently public Store binary predates the cold dependency-registration repair. The repaired source is automated-, Release-, iPhone-, and Apple-Watch-proven, but users receive it only after an owner-approved App Store upload and release." } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4b6d16..9ef6918 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: macos: name: macOS 26 / Xcode 26.6 / iPhone 17 runs-on: macos-26 - timeout-minutes: 45 + timeout-minutes: 60 env: DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer diff --git a/Hourleaf/App/HourleafApp.swift b/Hourleaf/App/HourleafApp.swift index e4bf104..4e7bf75 100644 --- a/Hourleaf/App/HourleafApp.swift +++ b/Hourleaf/App/HourleafApp.swift @@ -1,14 +1,36 @@ +import AppIntents import Foundation import SwiftUI @main @MainActor struct HourleafApp: App { - @StateObject private var launcher = HourleafAppLauncher( - arguments: ProcessInfo.processInfo.arguments - ) + @StateObject private var launcher: HourleafAppLauncher @AppStorage(AppAppearance.storageKey) private var appearanceRawValue = AppAppearance.system.rawValue + init() { + self.init( + arguments: ProcessInfo.processInfo.arguments, + appIntentDependencyManager: .shared + ) + } + + /// Build the launcher eagerly inside `App.init()`. App Intents may start + /// the process in the background without ever evaluating `body`, while a + /// StateObject's wrapped-value expression is otherwise allowed to remain + /// lazy. Apple requires App Intent dependencies to be registered as early + /// as possible during launch. + init( + arguments: [String], + appIntentDependencyManager: AppDependencyManager = .shared + ) { + let launcher = HourleafAppLauncher( + arguments: arguments, + appIntentDependencyManager: appIntentDependencyManager + ) + _launcher = StateObject(wrappedValue: launcher) + } + var body: some Scene { WindowGroup { HourleafLaunchView(launcher: launcher) @@ -68,11 +90,15 @@ final class HourleafAppLauncher: ObservableObject { private let isUITesting: Bool private let usesTestStore: Bool private let clock: @Sendable () -> Date + private let appIntentDependencyManager: AppDependencyManager private var pendingRecovery: PendingRecovery? private var pendingQuickEntryRoute = false private var didStart = false - init(arguments: [String]) { + init( + arguments: [String], + appIntentDependencyManager: AppDependencyManager = .shared + ) { let uiTesting = arguments.contains("-uiTesting") if uiTesting, arguments.contains("-resetTimeSelectionFeedbackUITest") { UserDefaults.standard.removeObject( @@ -91,6 +117,7 @@ final class HourleafAppLauncher: ObservableObject { isUITesting = uiTesting usesTestStore = uiTesting || arguments.contains("-freshInstallUITest") self.clock = clock + self.appIntentDependencyManager = appIntentDependencyManager if usesTestStore { let router = AppRouter() @@ -266,7 +293,8 @@ final class HourleafAppLauncher: ObservableObject { repository: runtime.repository, quickSurfaceHost: quickSurfaceHost, systemReloader: quickSurfaceSystemReloader - ) + ), + manager: appIntentDependencyManager ) if !usesTestStore { let watchReceiver = WatchTimeEntryReceiver( diff --git a/Hourleaf/AppIntents/RecordTimeIntent.swift b/Hourleaf/AppIntents/RecordTimeIntent.swift index 146338b..7c50b65 100644 --- a/Hourleaf/AppIntents/RecordTimeIntent.swift +++ b/Hourleaf/AppIntents/RecordTimeIntent.swift @@ -114,10 +114,11 @@ struct RecordTimeIntent: AppIntent { static var openAppWhenRun: Bool { false } static var authenticationPolicy: IntentAuthenticationPolicy { - // Ordinary authenticated execution is sufficient for the iPhone - // Shortcuts and Siri surfaces. Apple Watch requires a watchOS app of - // its own; a paired iPhone intent cannot execute on the Watch. - .requiresAuthentication + // Recording adds a validated entry but never reveals ledger contents. + // Allowing background execution keeps Siri and Shortcuts usable from + // the lock screen while Core Data still enforces the normal command + // validation and file-protection boundaries. + .alwaysAllowed } @Parameter(title: "intent.record.kind", default: .service) @@ -251,7 +252,7 @@ struct RecordCreditTimeIntent: AppIntent { static var openAppWhenRun: Bool { false } static var authenticationPolicy: IntentAuthenticationPolicy { - .requiresAuthentication + .alwaysAllowed } @Parameter( diff --git a/HourleafTests/AppIntentTests.swift b/HourleafTests/AppIntentTests.swift index 8189ea5..65badfa 100644 --- a/HourleafTests/AppIntentTests.swift +++ b/HourleafTests/AppIntentTests.swift @@ -219,11 +219,11 @@ final class AppIntentTests: XCTestCase { XCTAssertEqual(after.reportSnapshots.count, 1) } - func testIntentExecutionPoliciesStaySeparated() { + func testRecordIntentsAllowHandsFreeExecutionWithoutOpeningApp() { XCTAssertFalse(RecordTimeIntent.openAppWhenRun) - XCTAssertEqual(RecordTimeIntent.authenticationPolicy, .requiresAuthentication) + XCTAssertEqual(RecordTimeIntent.authenticationPolicy, .alwaysAllowed) XCTAssertFalse(RecordCreditTimeIntent.openAppWhenRun) - XCTAssertEqual(RecordCreditTimeIntent.authenticationPolicy, .requiresAuthentication) + XCTAssertEqual(RecordCreditTimeIntent.authenticationPolicy, .alwaysAllowed) XCTAssertTrue(OpenQuickEntryIntent.openAppWhenRun) XCTAssertEqual(OpenQuickEntryIntent.authenticationPolicy, .alwaysAllowed) } @@ -495,6 +495,19 @@ final class AppIntentTests: XCTestCase { XCTAssertEqual(resolvedSecond, expectedIdentity) } + func testAppInitializationRegistersIntentDependenciesBeforeBuildingTheUI() async throws { + let manager = AppDependencyManager() + + _ = HourleafApp( + arguments: ["Hourleaf", "-uiTesting"], + appIntentDependencyManager: manager + ) + + let resolvedIdentity = try await RepositoryIdentityProbeIntent(manager: manager) + .callAsFunction(donate: false) + XCTAssertFalse(resolvedIdentity.isEmpty) + } + func testExactlyThreeShortcutsArePromoted() { XCTAssertEqual(HourleafShortcuts.appShortcuts.count, 3) } diff --git a/HourleafTests/Watch/WatchTimeEntryTests.swift b/HourleafTests/Watch/WatchTimeEntryTests.swift index f30791e..1015f32 100644 --- a/HourleafTests/Watch/WatchTimeEntryTests.swift +++ b/HourleafTests/Watch/WatchTimeEntryTests.swift @@ -130,9 +130,9 @@ final class WatchTimeEntryTests: XCTestCase { XCTAssertTrue(source.contains("intent: WatchRecordServiceTimeIntent()")) XCTAssertTrue(source.contains("intent: WatchRecordCreditTimeIntent()")) XCTAssertFalse(source.contains("WatchRecordTimeIntent(kind:")) - XCTAssertFalse(source.contains(".alwaysAllowed")) + XCTAssertFalse(source.contains(".requiresAuthentication")) XCTAssertEqual( - source.components(separatedBy: ".requiresAuthentication").count - 1, + source.components(separatedBy: ".alwaysAllowed").count - 1, 2 ) } diff --git a/HourleafWatch/WatchRecordTimeIntent.swift b/HourleafWatch/WatchRecordTimeIntent.swift index 966938b..68cb6ef 100644 --- a/HourleafWatch/WatchRecordTimeIntent.swift +++ b/HourleafWatch/WatchRecordTimeIntent.swift @@ -15,7 +15,7 @@ struct WatchRecordServiceTimeIntent: AppIntent { IntentDescription("watch.intent.record.description") } static var openAppWhenRun: Bool { false } - static var authenticationPolicy: IntentAuthenticationPolicy { .requiresAuthentication } + static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } @Parameter( title: "watch.intent.duration", @@ -65,7 +65,7 @@ struct WatchRecordCreditTimeIntent: AppIntent { IntentDescription("watch.intent.record.description") } static var openAppWhenRun: Bool { false } - static var authenticationPolicy: IntentAuthenticationPolicy { .requiresAuthentication } + static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } @Parameter( title: "watch.intent.duration",