From 9f955a03598128b2d7d41808d653e60e7ec7a61a Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Mon, 19 Oct 2026 12:22:31 +0200 Subject: [PATCH 1/3] Adopt AgentGuidelines 0.0.38 --- AGENTS.md | 2 +- .../skills/agent-guidelines-audit/SKILL.md | 12 ++ AgentGuidelines/AGENTS.md | 2 +- AgentGuidelines/CHANGELOG.md | 28 ++++ .../Guidelines/GitHub/PullRequests.md | 50 +++++- AgentGuidelines/Guidelines/Swift/SwiftUI.md | 48 ++++++ AgentGuidelines/README.md | 4 +- .../Scripts/validate_guidelines.swift | 103 ++++++++++++ AgentGuidelines/Templates/AGENTS.md | 4 +- AgentGuidelines/Tests/run_tests.swift | 146 ++++++++++++++++++ AgentGuidelines/VERSION | 2 +- 11 files changed, 391 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2ef9416..938dcff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ In an initial review, report substantiated blockers together. A follow-up review The review-round budget below applies only to Codex GitHub reviews: the configured automatic Codex review and any manual `@codex review` request. It does not apply to ChatGPT review or reasoning delegated through Reasoning Relay. An otherwise-authorized Reasoning Relay workflow may request as many Relay review or follow-up delegations as its own governing workflow requires; those requests neither consume the Codex budget nor require repository-owner authorization under it. -Automatic Codex review is the initial Codex review. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](AgentGuidelines/Guidelines/GitHub/PullRequests.md). +When repository-specific evidence establishes that automatic Codex review is enabled and applies to the current pull request/head, it is the initial Codex review. Otherwise, an absent review does not establish a pending gate. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](AgentGuidelines/Guidelines/GitHub/PullRequests.md). ## Codex review scope diff --git a/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md b/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md index ead9b72..05bf9c6 100644 --- a/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md +++ b/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md @@ -28,6 +28,8 @@ Review the actual change rather than only checking whether files exist: - For Redux applications, trace actions, state, reducers, middleware, services, tools, presentation models, views, and side-effect results through the complete data flow. Confirm each Redux component folder contains only that component type. - Check that framework objects, persistence, logging, and asynchronous work remain in their allowed boundaries. - Check SwiftUI composition, narrow inputs, local versus durable state, localization, accessibility, and safe deterministic previews where applicable. +- For each new or changed SwiftUI `ScrollView`, apply the shared [scrolling guidance](../../../Guidelines/Swift/SwiftUI.md#scrolling): verify `.scrollBounceBehavior(.basedOnSize)` covers its scrolling axes on supported deployment targets, including scroll views in modal sheets. Check the modifier's environment scope around nested scrollables and require a documented interaction reason for a different bounce behavior. +- Perform the mandatory [native-component and presentation decision check](#audit-native-components-and-presentation) for changed SwiftUI controls and presentation behavior. - Check tests for the required framework, mirrored paths, shared tags, Given/When/Then structure, deterministic seams, and coverage of changed behavior and failure paths. - Trace every new or changed stateful, asynchronous, fallible, or lifecycle-oriented behavior and verify that its owning artifact emits privacy-safe AppLogger events for the meaningful success, failure, cancellation, recovery, and state-transition outcomes needed to diagnose it. Dependency declaration and target linkage alone do not establish logging coverage. Accept silence for pure values or utilities only when there is no meaningful event boundary and the implementation handoff records that deliberate decision. - Check logging ownership, subsystem, categories, emoji, privacy, severity, metadata stability, noise controls, and focused formatter or sink tests when logging changed. @@ -40,6 +42,16 @@ Review the actual change rather than only checking whether files exist: - Apply the shared [CI/CD guide](../../../Guidelines/CICD.md) to workflow and repository-automation changes. Search scripts, generated directories, workflow files, and documentation for fastlane adoption and treat it as forbidden without an exception path. Require focused ThatFactory tooling or repository-owned Swift scripts for CI/CD and delivery, and inspect new repository-owned executable scripts for compliance. For Swift-focused applications, games, and packages, accept Python or POSIX shell only when durable repository documentation identifies the missing Swift capability, exact script and task scope, runtime and dependency requirements, security and maintenance impact, validation method, and revisit or removal condition. Reject convenience, familiarity, shorter code, an existing interpreter, or another non-Swift script as justification. Treat every exception as narrow; the central `Scripts/swift_format.sh` wrapper is the retained documented exception for invoking Xcode's `swift-format` modes. - For pull-request or merge readiness, apply the root `## Code Review Rules`: confirm the Codex review covers the current head, no allowed Codex review round is pending, every Codex review thread has a disposition, and no unresolved P0/P1 blocker remains. Treat P2/P3 observations as non-blocking and never request another Codex review unless the repository owner explicitly authorizes it. This Codex review-round budget does not apply to otherwise-authorized Reasoning Relay/ChatGPT review delegations; do not block them waiting for a Codex-budget exception. +## Audit native components and presentation + +Apply the authoritative [SwiftUI native-component policy](../../../Guidelines/Swift/SwiftUI.md#native-components-and-presentation) to changed controls and presentation flows: + +1. Inspect changed custom modal hosts, overlay/scrim/offset code, and gesture routing in context. These are investigation signals, not blanket prohibitions on overlays or styling. Include existing hosts directly affected by the change, including discard dialogs; do not assume a native child makes every surrounding presentation compliant. +2. Record the supported interaction, native alternative, deployment-target availability, and who owns presentation and dismissal. Distinguish branded content within a native component from replacing its lifecycle. +3. For each custom equivalent, verify the capability-gap evidence, explicit repository-owner approval, durable consumer exception, and compensating validation required by the policy. A suitable native alternative without the required approved and documented exception blocks audit completion. Do not treat a mockup or unchanged mounted state as approval. +4. Trace child dismissal, parent draft ownership, nested picker/editor presenters, cancellation, and dismissal-completion cleanup through the affected hierarchy and asynchronous work. Confirm that a native nested sheet with branded content is accepted when ownership and evidence satisfy the policy. +5. Require the policy's [interaction evidence](../../../Guidelines/Swift/SwiftUI.md#required-interaction-evidence) for each affected flow. Inspect actual actions and observed outcomes, including recorded manual gesture verification when needed; static screenshots and no-op automation do not establish gesture behavior. Missing relevant runtime evidence blocks audit completion. Record an exact blocker for unavailable verification and include decisions, exception references, and evidence in the handoff. + ## Audit Xcode project settings For every checked-in `.xcodeproj`, read and apply the shared [Xcode project-settings guide](../../../Guidelines/Xcode/ProjectSettings.md): diff --git a/AgentGuidelines/AGENTS.md b/AgentGuidelines/AGENTS.md index 36e9089..8d4f74a 100644 --- a/AgentGuidelines/AGENTS.md +++ b/AgentGuidelines/AGENTS.md @@ -58,7 +58,7 @@ In an initial review, report substantiated blockers together. A follow-up review The review-round budget below applies only to Codex GitHub reviews: the configured automatic Codex review and any manual `@codex review` request. It does not apply to ChatGPT review or reasoning delegated through Reasoning Relay. An otherwise-authorized Reasoning Relay workflow may request as many Relay review or follow-up delegations as its own governing workflow requires; those requests neither consume the Codex budget nor require repository-owner authorization under it. -Automatic Codex review is the initial Codex review. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](Guidelines/GitHub/PullRequests.md). +When repository-specific evidence establishes that automatic Codex review is enabled and applies to the current pull request/head, it is the initial Codex review. Otherwise, an absent review does not establish a pending gate. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](Guidelines/GitHub/PullRequests.md). ## Validation diff --git a/AgentGuidelines/CHANGELOG.md b/AgentGuidelines/CHANGELOG.md index d82dd17..b116dde 100644 --- a/AgentGuidelines/CHANGELOG.md +++ b/AgentGuidelines/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to this project are documented in this file. +## [0.0.38] - 2026-10-15 + +### Added + +- Made size-based scroll bounce the SwiftUI `ScrollView` default on supported deployment targets, with axis and environment-scope guidance for nested scrolling and modal sheets. +- Added a completion-audit check for new or changed `ScrollView` bounce behavior and documented exceptions. + +## [0.0.37] - 2026-10-14 + +### Added + +- Made native SwiftUI and platform components the default for supported interactions, with approved durable exceptions required for verified native capability gaps. +- Defined presentation hierarchy, independent child dismissal, parent draft retention, cancellation, dismissal-completion cleanup, and required runtime interaction evidence. +- Added a mandatory completion-audit decision check for custom component and presentation lifecycle replacements, with validator and regression coverage. + +## [0.0.36] - 2026-10-09 + +### Added + +- Added SwiftUI animation guidance favoring purposeful native feedback, default SwiftUI animation, system transitions, and SF Symbol effects while keeping animations scoped and interactions responsive. +- Required an explicit task-prompt request before adding app-level Reduce Motion guards, while preserving accessibility behavior supplied automatically by native system components. + +## [0.0.35] - 2026-09-27 + +### Changed + +- Require positive repository-specific evidence before automatic Codex review becomes a merge gate. Track configuration separately from current-head execution, default undiscoverable settings to `unknown`, and bound deduplicated start-signal monitoring to five minutes. + ## [0.0.34] - 2026-09-20 ### Changed diff --git a/AgentGuidelines/Guidelines/GitHub/PullRequests.md b/AgentGuidelines/Guidelines/GitHub/PullRequests.md index 48b85df..e7456de 100644 --- a/AgentGuidelines/Guidelines/GitHub/PullRequests.md +++ b/AgentGuidelines/Guidelines/GitHub/PullRequests.md @@ -11,7 +11,7 @@ Use this guide whenever creating, reviewing, updating, or merging a GitHub pull - Follow the repository's pull-request template and local contribution instructions. - Run the relevant local validation and document anything that could not be run. - Open the pull request without auto-merge and keep it unmerged while automated or agent review is pending. Use draft state only when configured reviewers also run on drafts. -- When automatic Codex review is enabled, opening the pull request schedules the review. Do not also post `@codex review` or make another manual request; duplicate reviews waste review capacity and tokens. Do not request a Codex review manually unless the user explicitly asks for one. +- When repository-specific evidence establishes that automatic Codex review is enabled and the current pull request/head meets its trigger, track that review round. Do not also post `@codex review` or make another manual request; duplicate reviews waste review capacity and tokens. Do not request a Codex review manually unless the user explicitly asks for one. ## Consumer subtree review scope @@ -34,7 +34,7 @@ Only unresolved P0 and P1 findings block merge. A finding may be technically cor Opening a pull request starts review; it does not authorize merging it. -1. Wait for the configured Codex review to finish. No review yet means pending, not approved. +1. Establish the Codex review state from the evidence rules below. An absent review is pending only for a positively established current-head review round. Unknown or disabled configuration and no Codex activity do not block merge by themselves. 2. Record the reviewed head SHA and inspect all review summaries, inline threads, checks, and requested changes. 3. Assess each comment for technical correctness, severity, supported reachability, and root cause. 4. Give every thread one explicit disposition: `BLOCKER-P0`, `BLOCKER-P1`, `DEFER-P2`, `DEFER-P3`, `DECLINE`, or `DUPLICATE`. @@ -50,6 +50,40 @@ A thumbs-up or clean Codex review satisfies the agent-review step, but it does n ### Codex review state and round budget +#### Codex review evidence and state + +Track configuration and execution separately in the active task; these are not checked-in runtime files: + +```text +codex_review_configuration = enabled | disabled | unknown +codex_review_configuration_evidence = | unset +codex_review_execution = not_started | scheduled | processing | completed +codex_review_execution_evidence = | unset +codex_review_expected_sha = +codex_review_completed_sha = | unset +``` + +Start at `unknown`, `not_started`, the current head SHA, and an unset completed SHA. The executing agent is not assumed to have access to OpenAI's automatic-review configuration. Only positive repository-specific evidence may change configuration to `enabled` or `disabled`: a durable declaration in the consumer's root `AGENTS.md` or other tracked repository policy, explicit repository-owner confirmation in the active task, an authoritative repository or organization setting observed through an available interface, or another source whose semantics explicitly establish this repository's configuration. A repository may declare that automatic Codex pull-request review is enabled or disabled; no declaration is required. Generic AgentGuidelines or template wording, an automation prompt, another repository's setting, and a previous pull request's behavior are insufficient. + +Execution evidence must identify the active pull request and review round: a current-PR Codex processing reaction or equivalent event, a current review request/event with unambiguous round semantics, a submitted review covering the expected head, or another authoritative GitHub/OpenAI signal identifying the current PR/head. Absence of a review or reaction and elapsed time are not evidence. In particular, `unknown` plus no signal remains `not_started`, never `scheduled` or pending. + +Discovering `disabled` leaves execution `not_started` when no current activity exists. Discovering `enabled` alone does not schedule a review. Set `scheduled` only when positive evidence also establishes that the current PR/head meets the automatic trigger; start the bounded signal monitor then. A current-PR/current-head processing signal advances to `processing`. A completed review may advance `scheduled` or `processing` to `completed`; record its commit SHA when available. The gate is satisfied only when the reviewed SHA equals the expected SHA or another authoritative signal proves coverage of that head. A review for an earlier head is stale. + +When the head changes, set `codex_review_expected_sha` to the new head and clear `codex_review_completed_sha`. Terminate monitoring of the old head. Re-establish execution evidence for the new head; do not transfer `scheduled`, `processing`, or `completed`, or infer a new automatic round from the old one. + +Apply these outcomes to the current head: + +| Evidence | Execution | Merge gate | Start monitor | +| --- | --- | --- | --- | +| Enabled with a current-head processing signal | `processing` | Pending | No; processing already began | +| Enabled with a completed review covering the expected head and dispositioned findings | `completed` | Satisfied | No | +| Unknown with no signal or configuration evidence | `not_started` | Not blocked by absent Codex review | No | +| Disabled with no review | `not_started` | Not blocked by absent Codex review | No | +| Review completed for an old head | Re-establish for new head | Old review does not satisfy the gate | No inherited monitor | +| Enabled and positively eligible, with no start signal | `scheduled` | Pending while required | Yes, at most five minutes | + +For repeated identical snapshots, retain the fingerprint and poll count without re-analysis or notification. A signal inside the budget advances to `processing` while preserving configuration evidence and expected SHA. If the head changes during monitoring, terminate the old monitor and re-establish the new head's review state. If no signal appears by the deadline, terminate monitoring and report the unresolved verified state once; timeout never satisfies the gate. + This round budget applies only to Codex GitHub reviews: the configured automatic Codex review and any manual `@codex review` request. It does not apply to ChatGPT review or reasoning delegated through Reasoning Relay. An otherwise-authorized Reasoning Relay workflow may request as many Relay review or follow-up delegations as its own governing workflow requires; those requests neither consume this Codex budget nor require repository-owner authorization under it. Do not block an agentic goal waiting for a Codex-budget exception before issuing an otherwise-authorized Reasoning Relay request. Track enough Codex-review state to prevent duplicate requests and unbounded Codex review loops: @@ -82,6 +116,14 @@ Stop the review loop when no unresolved P0/P1 finding remains, every thread has ### Codex review monitoring +Monitor an automatic review for at most five minutes total from entry into `scheduled`, including time spent in `processing`. Take an initial snapshot, then at most one each around 30, 90, 180, and 300 seconds; equivalent non-accelerating schedules are allowed if they stop by five minutes. Do not create an indefinite recurring automation. A processing signal changes the execution state but does not reset the deadline or poll budget. A completion signal ends monitoring. Review completion is not assumed to occur within five minutes. + +A temporary monitor owns `started_at`, `deadline`, `poll_count`, `last_state_fingerprint`, and `last_observed_state`; discard them when it ends. Fingerprint at least repository, PR number, base SHA, head SHA, configuration, execution, signal state, review commit SHA, review-thread state, required checks, and mergeability. An identical fingerprint causes no substantive re-analysis or user notification. Continue only within the deadline. + +At the deadline, stop and terminate the monitor, report the observed facts once, and never infer approval. Keep `scheduled` only if positive current-head scheduling evidence remains; retain `processing` only while its current-head signal remains valid. Otherwise use the strongest evidence-supported state. Do not continue polling or block unrelated work. If a positively established review remains a required merge gate, surface that unresolved gate to the user. + +Any generated monitor prompt must preserve repository, PR number, expected head SHA, configuration and its authoritative evidence, current execution state, monitor start, and deadline. Without configuration evidence, do not create a monitor for an absent review. A later heartbeat must not reconstruct `enabled` from its own prompt. + Use GitHub review data, reactions, and checks together. An eyes reaction means Codex is processing the pull request; it is not an approval. A thumbs-up means the review completed without suggestions. A submitted review means its inline threads must be assessed individually. ```text @@ -151,7 +193,7 @@ gh api graphql --paginate \ -F thread= ``` -Continue polling only while an allowed review round is pending. Inspect every returned page for reactions, review threads, and thread comments. Do not treat missing comments, a pending reaction, truncated results, or elapsed time as review completion, and do not submit a duplicate request merely because polling has not completed. +Poll for automatic start or completion only under the five-minute ceiling above. After a verified processing signal, inspect every returned page for reactions, review threads, and thread comments when checking completion. Do not treat missing comments, a pending reaction, truncated results, or elapsed time as review completion, and do not submit a duplicate request merely because polling has not completed. ## Merge method @@ -161,7 +203,7 @@ ThatFactory repositories use squash merges by default. Do not attempt a merge co Do not merge while any of the following is true: -- Codex review is still pending; +- a positively established current-head Codex review round is `scheduled` or `processing` and remains a required gate; - an unresolved P0/P1 finding remains; - a review thread lacks an explicit disposition or remains unresolved; - a required check is pending or failing; diff --git a/AgentGuidelines/Guidelines/Swift/SwiftUI.md b/AgentGuidelines/Guidelines/Swift/SwiftUI.md index 82b7cbb..450c112 100644 --- a/AgentGuidelines/Guidelines/Swift/SwiftUI.md +++ b/AgentGuidelines/Guidelines/Swift/SwiftUI.md @@ -43,6 +43,13 @@ With SDKs where `@State` is a macro, do not give a state property a declaration - Avoid unstable environment defaults and freshly created closures that invalidate large subtrees. - Do not hide unstable values behind fake `Equatable` implementations. +## Scrolling + +- Apply `.scrollBounceBehavior(.basedOnSize)` to new or changed `ScrollView` instances on supported deployment targets, especially inside modally presented sheets. Content that fits should stay still; content that exceeds the viewport can still scroll and bounce. +- Match the modifier's `axes` to the scroll view. The default affects only the vertical axis, so specify `.horizontal` for horizontal scrolling and both axes when needed. +- Keep the modifier on the intended scroll view or its narrowest suitable container. The setting propagates through the environment to descendant scrollable views, so check nested scrolling before applying it to a broader hierarchy. +- Preserve a different bounce behavior only when the supported interaction requires it, and record that reason in consumer guidance. Check Apple API availability for the consumer's deployment targets before applying the modifier. + ## Modern APIs and scope - Do not introduce APIs that current Xcode documentation identifies as deprecated or soft-deprecated. @@ -50,6 +57,47 @@ With SDKs where `@State` is a macro, do not give a state property a declaration - For SDK-sensitive features, search current Apple documentation through Xcode rather than relying on remembered signatures. - Apply the current Xcode SwiftUI skill when a new SDK changes source behavior, builder resolution, state initialization, or modifier availability. +## Native components and presentation + +- Prefer native SwiftUI or platform controls and presentation APIs whenever they express the supported interaction. Evaluate sheets, alerts, confirmation dialogs, navigation, menus, pickers, popovers, and similar components before implementing an equivalent. Consult official Apple/Xcode documentation for behavior and availability on the current deployment target, following [documentation lookup](../Xcode/MCP.md#documentation-lookup). +- Keep branded content and styling inside native components. Custom fonts, button styles, and content inside a native sheet are compatible with this policy when the system retains presentation and dismissal ownership. +- Do not substitute a sheet-shaped `ZStack`, offset animation, or custom scrim for a suitable native sheet API. The same rule applies to other simulated system components, including custom discard dialogs. Pixel fidelity to a static design and preserving mounted state are insufficient reasons to replace native presentation behavior; a mockup is not approval for an exception. +- Use a custom equivalent only for an actual native capability gap verified against the supported platform and deployment target. Before implementation, obtain explicit repository-owner approval and record a durable exception in consumer documentation: the required interaction, evaluated native alternatives, concrete missing capability and documentation or reproducible evidence, approved scope, lifecycle ownership, compensating validation, and revisit condition. Do not invent a capability gap or infer approval from existing custom code. An unapproved or undocumented replacement blocks completion. + +### Presentation ownership + +- Present from the active hierarchy. When a child presentation launches a system picker or editor, attach that presentation to the active child hierarchy rather than an obscured ancestor or unrelated host. +- Give each child independent dismissal. Read the dismissal action in the child's presentation environment or use its own presentation binding; do not route a child close button or downward dismissal gesture to the parent editor. Keep parent-owned draft state alive across child presentation and dismissal without committing or discarding it implicitly. +- Make cancellation idempotent across explicit cancel, interactive dismissal, parent dismissal, and asynchronous callbacks. Cancel owned work and reject stale completions after dismissal or replacement so an old result cannot mutate a retained draft or a later presentation. +- Perform cleanup that depends on completed dismissal through the presentation's dismissal-completion callback, such as `onDismiss`, rather than treating a dismiss request, animation delay, or arbitrary disappearance as proof of completion. Coordinate cleanup with cancellation so repeated callbacks cannot clear parent state or a newer child session. + +### Required interaction evidence + +For changed native presentations and approved custom equivalents, exercise the supported lifecycle on a simulator or device using [runtime interaction and reporting](../Xcode/MCP.md#build-run-and-interaction). Record the platform, scenario, action, observed outcome, and relevant hierarchy or visual evidence: + +- Verify presentation and dismissal animation/dimming, supported interactive dismissal and gesture routing, and parent survival with draft retention. If interactive dismissal is intentionally disabled, verify the restriction and the explicit exit path. Exercise reopening and repeated cancellation. +- Launch and dismiss any nested system picker or editor from the active child; confirm the expected presentation remains active after each exit. +- Inspect safe areas, controls and shadow clipping, keyboard/focus restoration, and relevant Dynamic Type and localization layouts during the flow. +- Exercise asynchronous cancellation and stale completion after dismissal or replacement when work can outlive the presentation; use deterministic tests where they improve coverage of these races alongside runtime interaction. + +A static screenshot or a no-op automation call is not evidence that gestures work. Require an actual gesture and its observed result, or recorded manual verification when automation cannot exercise it. Missing relevant runtime evidence blocks completion; report the exact unverified behavior instead of claiming the lifecycle passed. + +Use these decisions when auditing an implementation: + +| Decision | Audit outcome | +| --- | --- | +| Simulated sheet with a suitable native API and no approved durable exception | Blocked | +| Native nested sheet with branded content, correct ownership, and required interaction evidence | Accepted | +| Custom equivalent for a verified capability gap with owner approval, durable exception, and compensating evidence | Accepted within the approved scope | +| Presentation with missing relevant runtime evidence | Blocked | + +## Animation + +- Use native SwiftUI animation to make apps fluid rather than static. Apply it purposefully to buttons, transitions, interactions, selection, colors, and other visible state changes that benefit from feedback; do not animate every update or add motion for its own sake. +- Prefer the default SwiftUI animation, using `withAnimation { ... }` or `.animation(.default, value: ...)`, unless the task prompt explicitly requests a specific animation. Prefer system-provided transitions and SF Symbol effects when they fit the interaction instead of recreating them with custom timing, curves, or rendering. +- Scope animation to the relevant state change or view. Preserve responsive controls, stable layout, and uninterrupted user interaction; avoid blanket animations that also animate unrelated state changes. +- Do not introduce app-level Reduce Motion guards unless the task prompt explicitly requests them. This includes reading `accessibilityReduceMotion` to replace animation with `nil`, remove symbol effects, or otherwise suppress app-authored animation. Preserve any accessibility behavior provided automatically by native system components; this policy governs additional app-level overrides. + ## Previews - Put previews at the end of the file under `// MARK: - Preview`. diff --git a/AgentGuidelines/README.md b/AgentGuidelines/README.md index caab867..85b73db 100644 --- a/AgentGuidelines/README.md +++ b/AgentGuidelines/README.md @@ -91,7 +91,7 @@ From the consumer repository root, install a tagged release: git subtree add \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.34 \ + 0.0.38 \ --squash ``` @@ -147,7 +147,7 @@ Review the target release's changelog, then pull it deliberately: git subtree pull \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.34 \ + 0.0.38 \ --squash ``` diff --git a/AgentGuidelines/Scripts/validate_guidelines.swift b/AgentGuidelines/Scripts/validate_guidelines.swift index 7c2dddf..b82269f 100755 --- a/AgentGuidelines/Scripts/validate_guidelines.swift +++ b/AgentGuidelines/Scripts/validate_guidelines.swift @@ -15,6 +15,7 @@ let swiftFormatConfiguration = root.appendingPathComponent("Configurations/Swift let editorConfiguration = root.appendingPathComponent("Configurations/Swift/.editorconfig") let swiftFormatScript = root.appendingPathComponent("Scripts/swift_format.sh") let swiftFormatGuideline = root.appendingPathComponent("Guidelines/Swift/SwiftFormat.md") +let swiftUIGuideline = root.appendingPathComponent("Guidelines/Swift/SwiftUI.md") let localizationGuideline = root.appendingPathComponent("Guidelines/Localization.md") let appStoreGuideline = root.appendingPathComponent("Guidelines/AppStore.md") let xcodeProjectSettingsGuideline = root.appendingPathComponent("Guidelines/Xcode/ProjectSettings.md") @@ -34,6 +35,7 @@ let cicdGuideline = root.appendingPathComponent("Guidelines/CICD.md") let documentationGuideline = root.appendingPathComponent("Guidelines/Documentation.md") let packagesGuideline = root.appendingPathComponent("Guidelines/Packages.md") let agentsTemplate = root.appendingPathComponent("Templates/AGENTS.md") +let pullRequestsGuideline = root.appendingPathComponent("Guidelines/GitHub/PullRequests.md") let gitignoreTemplate = root.appendingPathComponent("Templates/.gitignore") let gitignoreGuideline = root.appendingPathComponent("Guidelines/Git/IgnoreFiles.md") @@ -672,6 +674,46 @@ func validateExternalDependencyPolicy(_ errors: inout [String]) { } } +/// Protects the native-component decision, ownership, and runtime-evidence policy. +func validateNativeComponentPolicy(_ errors: inout [String]) { + guard let contents = readText(swiftUIGuideline, errors: &errors) else { return } + let required = [ + "## Native components and presentation", + "Prefer native SwiftUI or platform controls and presentation APIs", + "sheets, alerts, confirmation dialogs, navigation, menus, pickers, popovers", + "Custom fonts, button styles, and content inside a native sheet are compatible", + "Do not substitute a sheet-shaped `ZStack`, offset animation, or custom scrim", + "Pixel fidelity to a static design and preserving mounted state are insufficient", + "actual native capability gap verified against the supported platform and deployment target", + "Before implementation, obtain explicit repository-owner approval and record a durable exception", + "Do not invent a capability gap or infer approval from existing custom code", + "An unapproved or undocumented replacement blocks completion", + "attach that presentation to the active child hierarchy", + "Give each child independent dismissal", + "Keep parent-owned draft state alive", + "Make cancellation idempotent", + "reject stale completions after dismissal or replacement", + "presentation's dismissal-completion callback", + "## Required interaction evidence", + "presentation and dismissal animation/dimming", + "interactive dismissal and gesture routing", + "parent survival with draft retention", + "safe areas, controls and shadow clipping, keyboard/focus restoration", + "Dynamic Type and localization", + "Exercise asynchronous cancellation and stale completion", + "A static screenshot or a no-op automation call is not evidence that gestures work", + "Require an actual gesture and its observed result, or recorded manual verification", + "Missing relevant runtime evidence blocks completion", + "Simulated sheet with a suitable native API and no approved durable exception | Blocked", + "Native nested sheet with branded content, correct ownership, and required interaction evidence | Accepted", + "Custom equivalent for a verified capability gap with owner approval, durable exception, and compensating evidence | Accepted within the approved scope", + "Presentation with missing relevant runtime evidence | Blocked", + ] + for value in required where !contents.contains(value) { + errors.append("Guidelines/Swift/SwiftUI.md: missing native-component policy: '\(value)'") + } +} + /// Validates the completion-audit skill and native helper scripts. func validateAuditSkill(_ errors: inout [String]) { guard let contents = readText(auditSkill, errors: &errors) else { @@ -682,6 +724,19 @@ func validateAuditSkill(_ errors: inout [String]) { "before claiming completion": "completion trigger", "git diff --check": "diff validation", "validate_consumer_setup.swift": "consumer integration validation", + "Perform the mandatory [native-component and presentation decision check]": "mandatory native-component audit", + "## Audit native components and presentation": "native-component audit section", + "Guidelines/Swift/SwiftUI.md#native-components-and-presentation": "authoritative native-component policy link", + "custom modal hosts, overlay/scrim/offset code, and gesture routing": "custom presentation inspection", + "native alternative, deployment-target availability": "native alternative decision evidence", + "explicit repository-owner approval, durable consumer exception": "custom component exception validation", + "required approved and documented exception blocks audit completion": "native-component audit stopping rule", + "child dismissal, parent draft ownership, nested picker/editor presenters": "presentation hierarchy audit", + "cancellation, and dismissal-completion cleanup": "presentation cancellation audit", + "native nested sheet with branded content is accepted": "native branded content acceptance", + "Guidelines/Swift/SwiftUI.md#required-interaction-evidence": "presentation runtime evidence link", + "static screenshots and no-op automation do not establish gesture behavior": "gesture evidence audit", + "Missing relevant runtime evidence blocks audit completion": "runtime evidence stopping rule", "format-and-lint": "local Swift-format audit", "lint-strict": "strict Swift-format CI audit", "AppLogger": "AppLogger integration audit", @@ -772,6 +827,52 @@ func validateAuditSkill(_ errors: inout [String]) { } } +/// Validates positive evidence and bounded monitoring for automatic Codex reviews. +func validateCodexReviewEvidence(_ errors: inout [String]) { + guard let guide = readText(pullRequestsGuideline, errors: &errors) else { return } + let required = [ + "codex_review_configuration = enabled | disabled | unknown", + "codex_review_execution = not_started | scheduled | processing | completed", + "codex_review_expected_sha = ", + "Only positive repository-specific evidence", + "unknown` plus no signal remains `not_started`", + "Discovering `enabled` alone does not schedule a review", + "reviewed SHA equals the expected SHA", + "at most five minutes total from entry into `scheduled`, including time spent in `processing`", + "A processing signal changes the execution state but does not reset the deadline or poll budget", + "An identical fingerprint causes no substantive re-analysis or user notification", + "Without configuration evidence, do not create a monitor", + "do not transfer `scheduled`, `processing`, or `completed`", + "At the deadline, stop and terminate the monitor, report the observed facts once, and never infer approval", + "If the head changes during monitoring, terminate the old monitor", + ] + for concept in required where !guide.contains(concept) { + errors.append("Guidelines/GitHub/PullRequests.md: missing Codex review evidence rule: \(concept)") + } + for forbidden in ["No review yet means pending", "Wait for the configured Codex review to finish"] + where guide.contains(forbidden) { + errors.append("Guidelines/GitHub/PullRequests.md: unconditional Codex review gate: \(forbidden)") + } + let scenarioOutcomes = [ + "Enabled with a current-head processing signal | `processing` | Pending | No; processing already began", + "Enabled with a completed review covering the expected head and dispositioned findings | `completed` | Satisfied | No", + "Unknown with no signal or configuration evidence | `not_started` | Not blocked by absent Codex review | No", + "Disabled with no review | `not_started` | Not blocked by absent Codex review | No", + "Review completed for an old head | Re-establish for new head | Old review does not satisfy the gate | No inherited monitor", + "Enabled and positively eligible, with no start signal | `scheduled` | Pending while required | Yes, at most five minutes", + "A signal inside the budget advances to `processing` while preserving configuration evidence and expected SHA", + "If no signal appears by the deadline, terminate monitoring and report the unresolved verified state once; timeout never satisfies the gate", + ] + for outcome in scenarioOutcomes where !guide.contains(outcome) { + errors.append("Guidelines/GitHub/PullRequests.md: missing Codex review scenario outcome: \(outcome)") + } + if let template = readText(agentsTemplate, errors: &errors), + !template.contains("this template does not establish that automatic review is configured") + { + errors.append("Templates/AGENTS.md: missing unknown-configuration safeguard") + } +} + /// Validates the reusable ignore template and its shared reconciliation policy. func validateGitignoreGuidance(_ errors: inout [String]) { guard let template = readText(gitignoreTemplate, errors: &errors) else { return } @@ -822,6 +923,8 @@ func main() -> Int32 { validateXcodeProjectSettingsGuideline(&errors) validatePackageCompilerSettingsGuideline(&errors) validateExternalDependencyPolicy(&errors) + validateNativeComponentPolicy(&errors) + validateCodexReviewEvidence(&errors) validateGitignoreGuidance(&errors) validateExecutable(consumerSetupScript, description: "consumer setup validator", errors: &errors) validateAuditSkill(&errors) diff --git a/AgentGuidelines/Templates/AGENTS.md b/AgentGuidelines/Templates/AGENTS.md index 092c1a9..c57b8a9 100644 --- a/AgentGuidelines/Templates/AGENTS.md +++ b/AgentGuidelines/Templates/AGENTS.md @@ -28,6 +28,8 @@ Read only the guides relevant to the task: For an application that uses Redux, also read [Redux architecture](AgentGuidelines/Guidelines/Architecture/Redux.md). +For Codex pull-request review, use the linked review evidence rules. If authoritative repository-specific evidence establishes that automatic review is enabled, apply its current-head gate. If the executing agent cannot establish the setting, record it as `unknown`; this template does not establish that automatic review is configured. + Keep the following observability contract in the consumer repository's root `AGENTS.md` so implementation agents treat runtime diagnostics as part of lifecycle work. Copy it unchanged and update it when the marker version changes in this template. ```md @@ -90,7 +92,7 @@ In an initial review, report substantiated blockers together. A follow-up review The review-round budget below applies only to Codex GitHub reviews: the configured automatic Codex review and any manual `@codex review` request. It does not apply to ChatGPT review or reasoning delegated through Reasoning Relay. An otherwise-authorized Reasoning Relay workflow may request as many Relay review or follow-up delegations as its own governing workflow requires; those requests neither consume the Codex budget nor require repository-owner authorization under it. -Automatic Codex review is the initial Codex review. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](AgentGuidelines/Guidelines/GitHub/PullRequests.md). +When repository-specific evidence establishes that automatic Codex review is enabled and applies to the current pull request/head, it is the initial Codex review. Otherwise, an absent review does not establish a pending gate. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](AgentGuidelines/Guidelines/GitHub/PullRequests.md). ## Codex review scope diff --git a/AgentGuidelines/Tests/run_tests.swift b/AgentGuidelines/Tests/run_tests.swift index 57fb159..5d1de3f 100755 --- a/AgentGuidelines/Tests/run_tests.swift +++ b/AgentGuidelines/Tests/run_tests.swift @@ -120,6 +120,152 @@ func localization(_ value: String, state: String = "translated") -> String { } let tests: [(String, () throws -> Void)] = [ + ( + "native-component validator rejects policy and decision drift", + { + let mutations = [ + "Do not substitute a sheet-shaped `ZStack`, offset animation, or custom scrim", + "sheets, alerts, confirmation dialogs, navigation, menus, pickers, popovers", + "Before implementation, obtain explicit repository-owner approval and record a durable exception", + "Pixel fidelity to a static design and preserving mounted state are insufficient", + "Give each child independent dismissal", + "Keep parent-owned draft state alive", + "attach that presentation to the active child hierarchy", + "Make cancellation idempotent", + "reject stale completions after dismissal or replacement", + "presentation's dismissal-completion callback", + "Require an actual gesture and its observed result, or recorded manual verification", + "safe areas, controls and shadow clipping, keyboard/focus restoration", + "Dynamic Type and localization", + "Simulated sheet with a suitable native API and no approved durable exception | Blocked", + "Native nested sheet with branded content, correct ownership, and required interaction evidence | Accepted", + "Custom equivalent for a verified capability gap with owner approval, durable exception, and compensating evidence | Accepted within the approved scope", + "Presentation with missing relevant runtime evidence | Blocked", + ] + for original in mutations { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guide = fixture.appendingPathComponent("Guidelines/Swift/SwiftUI.md") + let contents = try String(contentsOf: guide, encoding: .utf8) + try require(contents.contains(original), "missing test fixture text: \(original)") + try write(contents.replacingOccurrences(of: original, with: "REMOVED"), to: guide) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "native-component policy mutation unexpectedly passed: \(original)") + try require(result.output.contains("missing native-component policy"), result.output) + } + } + } + ), + ( + "native-component validator rejects audit gate drift", + { + let mutations = [ + "Perform the mandatory [native-component and presentation decision check]", + "Guidelines/Swift/SwiftUI.md#native-components-and-presentation", + "custom modal hosts, overlay/scrim/offset code, and gesture routing", + "native alternative, deployment-target availability", + "explicit repository-owner approval, durable consumer exception", + "required approved and documented exception blocks audit completion", + "child dismissal, parent draft ownership, nested picker/editor presenters", + "cancellation, and dismissal-completion cleanup", + "native nested sheet with branded content is accepted", + "Guidelines/Swift/SwiftUI.md#required-interaction-evidence", + "static screenshots and no-op automation do not establish gesture behavior", + "Missing relevant runtime evidence blocks audit completion", + ] + for original in mutations { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let skill = fixture.appendingPathComponent(".agents/skills/agent-guidelines-audit/SKILL.md") + let contents = try String(contentsOf: skill, encoding: .utf8) + try require(contents.contains(original), "missing test fixture text: \(original)") + try write(contents.replacingOccurrences(of: original, with: "REMOVED"), to: skill) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "native-component audit mutation unexpectedly passed: \(original)") + try require(result.output.contains("SKILL.md: missing"), result.output) + } + } + } + ), + ( + "review evidence validator rejects an unconditional pending gate", + { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guide = fixture.appendingPathComponent("Guidelines/GitHub/PullRequests.md") + let contents = try String(contentsOf: guide, encoding: .utf8) + try write(contents + "\nNo review yet means pending.\n", to: guide) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "unconditional Codex gate unexpectedly passed") + try require(result.output.contains("unconditional Codex review gate"), result.output) + } + } + ), + ( + "review evidence validator requires unknown state", + { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guide = fixture.appendingPathComponent("Guidelines/GitHub/PullRequests.md") + let contents = try String(contentsOf: guide, encoding: .utf8) + try write( + contents.replacingOccurrences(of: "enabled | disabled | unknown", with: "enabled | disabled"), + to: guide) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "missing unknown state unexpectedly passed") + try require(result.output.contains("missing Codex review evidence rule"), result.output) + } + } + ), + ( + "review evidence validator protects scenario outcomes and deadline", + { + let mutations = [ + ( + "Disabled with no review | `not_started` | Not blocked by absent Codex review | No", + "Disabled with no review | `not_started` | Pending | Yes" + ), + ( + "Enabled with a current-head processing signal | `processing` | Pending", + "Enabled with a current-head processing signal | `processing` | Satisfied" + ), + ( + "Enabled with a completed review covering the expected head and dispositioned findings | `completed` | Satisfied", + "Enabled with a completed review covering the expected head and dispositioned findings | `completed` | Pending" + ), + ( + "Review completed for an old head | Re-establish for new head", + "Review completed for an old head | Completed for new head" + ), + ( + "An identical fingerprint causes no substantive re-analysis or user notification", + "An identical fingerprint may trigger another notification" + ), + ("including time spent in `processing`", "excluding time spent in `processing`"), + ( + "At the deadline, stop and terminate the monitor, report the observed facts once, and never infer approval", + "At the deadline, keep waiting" + ), + ] + for (original, replacement) in mutations { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guide = fixture.appendingPathComponent("Guidelines/GitHub/PullRequests.md") + let contents = try String(contentsOf: guide, encoding: .utf8) + try require(contents.contains(original), "missing test fixture text: \(original)") + try write(contents.replacingOccurrences(of: original, with: replacement), to: guide) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "review scenario mutation unexpectedly passed: \(original)") + try require(result.output.contains("Codex review"), result.output) + } + } + } + ), ( "repository validator accepts the source tree", { diff --git a/AgentGuidelines/VERSION b/AgentGuidelines/VERSION index bb951c8..e4a35e8 100644 --- a/AgentGuidelines/VERSION +++ b/AgentGuidelines/VERSION @@ -1 +1 @@ -0.0.34 +0.0.38 From 23dd20737b958c4f96289b20301fe1a8f04476a9 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Mon, 19 Oct 2026 12:27:38 +0200 Subject: [PATCH 2/3] Add native Image Playground opening and dismissal lifecycle --- .github/workflows/ci-pr.yml | 10 + .github/workflows/ci.yml | 10 + CHANGELOG.md | 18 +- README.md | 30 +-- .../ImagePlaygroundBridgeLaunchFailure.swift | 8 + .../ImagePlaygroundPresentationAdapter.swift | 69 +++++++ .../ImagePlaygroundPresentationAnchor.swift | 18 ++ .../ImagePlaygroundPresentationDriver.swift | 15 ++ .../ImagePlaygroundPresentationSession.swift | 158 +++++++++++++++ .../ImagePlaygroundUIKitDriver.swift | 114 +++++++++++ .../IntelligenceBridge.md | 11 +- .../IntelligenceBridgeLogging.swift | 23 ++- .../View+ImagePlaygroundPresentation.swift | 33 ++++ ...gePlaygroundPresentationSessionTests.swift | 185 ++++++++++++++++++ .../IntelligenceBridgeTests.swift | 10 + 15 files changed, 690 insertions(+), 22 deletions(-) create mode 100644 Sources/IntelligenceBridge/ImagePlaygroundBridgeLaunchFailure.swift create mode 100644 Sources/IntelligenceBridge/ImagePlaygroundPresentationAdapter.swift create mode 100644 Sources/IntelligenceBridge/ImagePlaygroundPresentationAnchor.swift create mode 100644 Sources/IntelligenceBridge/ImagePlaygroundPresentationDriver.swift create mode 100644 Sources/IntelligenceBridge/ImagePlaygroundPresentationSession.swift create mode 100644 Sources/IntelligenceBridge/ImagePlaygroundUIKitDriver.swift create mode 100644 Sources/IntelligenceBridge/View+ImagePlaygroundPresentation.swift create mode 100644 Tests/IntelligenceBridgeTests/ImagePlaygroundPresentationSessionTests.swift diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 3302c06..8387109 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -48,3 +48,13 @@ jobs: - name: Run Tests run: swift test -v + + ios-build: + name: iOS Build + runs-on: [self-hosted, macOS] + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Build Native Presentation Adapter + run: xcodebuild -scheme IntelligenceBridge -destination 'generic/platform=iOS Simulator' build CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 862b4c0..2a9103c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,3 +46,13 @@ jobs: - name: Run Tests run: swift test -v + + ios-build: + name: iOS Build + runs-on: [self-hosted, macOS] + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Build Native Presentation Adapter + run: xcodebuild -scheme IntelligenceBridge -destination 'generic/platform=iOS Simulator' build CODE_SIGNING_ALLOWED=NO diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a3304..62650aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,24 @@ All notable changes to IntelligenceBridge are documented here. ## Unreleased +## 0.3.0 — 2026-10-19 + +### Added + +- Add an iOS native presentation adapter with confirmed opening, completed dismissal, and typed unavailable or opening-timeout outcomes. Its configurable 12-second opening deadline never limits active editing or generation. +- Serialize terminal callbacks and cleanup, reject stale callbacks, and preserve synchronous temporary-image consumption before dismissal. +- Add deterministic lifecycle tests and privacy-safe presentation diagnostics. + +### Changed + +- Adopt AgentGuidelines `0.0.38`. The existing cross-platform SwiftUI sheet API remains source compatible. + ## 0.2.0 — 2026-09-20 ### Added -- Add a Foundation Models availability value that maps Apple's on-device model state to stable reasons and reports - unsupported platforms explicitly. -- Re-export Apple's Foundation Models APIs on supported platforms so consumers can use `SystemLanguageModel`, - `LanguageModelSession`, `@Generable`, and `@Guide` directly. +- Add a Foundation Models availability value that maps Apple's on-device model state to stable reasons and reports unsupported platforms explicitly. +- Re-export Apple's Foundation Models APIs on supported platforms so consumers can use `SystemLanguageModel`, `LanguageModelSession`, `@Generable`, and `@Guide` directly. ### Changed diff --git a/README.md b/README.md index c36f819..e233f55 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,7 @@ # IntelligenceBridge -IntelligenceBridge is a focused Apple-platform integration layer for Apple intelligence capabilities such as Image Playground -and Foundation Models. It makes platform availability, framework integration, and presentation seams consistent without -hiding Apple's frameworks or taking ownership of product prompts and persistence. 🧠 +IntelligenceBridge is a focused Apple-platform integration layer for Apple intelligence capabilities such as Image Playground and Foundation Models. It makes platform availability, framework integration, and presentation seams consistent without hiding Apple's frameworks or taking ownership of product prompts and persistence. 🧠 Image Playground is the package's first capability. Its existing API centralizes availability checks, concept setup, SwiftUI presentation wiring, cancellation, and temporary result handling while leaving generated-media persistence to the host application. @@ -34,10 +32,23 @@ content The presentation API is available on Apple platforms that support Image Playground. The package also compiles on tvOS so shared multiplatform application targets can resolve it, while ``ImagePlaygroundBridgeAvailability/current`` reports unavailable there. -Foundation Models support preserves Apple's native API surface. Importing IntelligenceBridge on a supported platform also -makes `SystemLanguageModel`, `LanguageModelSession`, `@Generable`, and `@Guide` available. Use the bridge availability value -to decide whether to offer model-backed behavior while keeping prompts, sessions, generated values, and product policy in -the host application. +On iOS, use `imagePlaygroundBridgePresentation` when the host needs to recover from a silent failure to open. It presents Apple's `ImagePlaygroundViewController` through an owned UIKit anchor, confirms opening, and bounds only the opening transition with a configurable 12-second deadline. It does not time out editing or generation, or detect readiness of Apple's internal generation interface. + +```swift +content.imagePlaygroundBridgePresentation( + isPresented: $isPresented, + request: request, + onOpened: { /* Stop the host's opening indicator. */ }, + onCompletion: { result in /* Consume result.temporaryURL synchronously. */ }, + onCancellation: { /* Record silent cancellation. */ }, + onLaunchFailure: { failure in /* Record unavailable or timedOut. */ }, + onDismissed: { /* Remove loading or show the host's recovery alert. */ } +) +``` + +Keep the modifier mounted until `onDismissed`. Completion and cancellation precede native dismissal; launch failures arrive after native cleanup, immediately before `onDismissed`. The presentation binding becomes false when dismissal finishes. Removing the host invalidates the attempt, dismisses its editor, and suppresses further host callbacks. Prompts, draft state, image decoding and storage, and error text remain the application's responsibility. The existing `imagePlaygroundBridgeSheet` API remains available unchanged on its supported platforms. + +Foundation Models support preserves Apple's native API surface. Importing IntelligenceBridge on a supported platform also makes `SystemLanguageModel`, `LanguageModelSession`, `@Generable`, and `@Guide` available. Use the bridge availability value to decide whether to offer model-backed behavior while keeping prompts, sessions, generated values, and product policy in the host application. ```swift import IntelligenceBridge @@ -49,10 +60,7 @@ guard FoundationModelBridgeAvailability.current == .available else { let session = LanguageModelSession() ``` -The availability mapping follows Apple's -[`SystemLanguageModel.Availability`](https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel/availability-swift.enum). -It distinguishes disabled Apple Intelligence, an ineligible device, a model that is not ready, and an unsupported platform. -The package does not wrap `LanguageModelSession` or introduce a generic language-model abstraction. +The availability mapping follows Apple's [`SystemLanguageModel.Availability`](https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel/availability-swift.enum). It distinguishes disabled Apple Intelligence, an ineligible device, a model that is not ready, and an unsupported platform. The package does not wrap `LanguageModelSession` or introduce a generic language-model abstraction. ## Documentation diff --git a/Sources/IntelligenceBridge/ImagePlaygroundBridgeLaunchFailure.swift b/Sources/IntelligenceBridge/ImagePlaygroundBridgeLaunchFailure.swift new file mode 100644 index 0000000..52e2e24 --- /dev/null +++ b/Sources/IntelligenceBridge/ImagePlaygroundBridgeLaunchFailure.swift @@ -0,0 +1,8 @@ +/// A failure to open the native editor. Generation inside an opened editor has no deadline. +public enum ImagePlaygroundBridgeLaunchFailure: Equatable, Sendable { + /// The native editor did not finish opening before the opening deadline. + case timedOut + + /// The current device cannot present Image Playground. + case unavailable +} diff --git a/Sources/IntelligenceBridge/ImagePlaygroundPresentationAdapter.swift b/Sources/IntelligenceBridge/ImagePlaygroundPresentationAdapter.swift new file mode 100644 index 0000000..2a32463 --- /dev/null +++ b/Sources/IntelligenceBridge/ImagePlaygroundPresentationAdapter.swift @@ -0,0 +1,69 @@ +#if os(iOS) + import SwiftUI + + /// Retains one native driver/session across SwiftUI updates. + struct ImagePlaygroundPresentationAdapter: UIViewControllerRepresentable { + @Binding var isPresented: Bool + let request: ImagePlaygroundBridgeRequest + let openingTimeout: Duration + let onOpened: @MainActor () -> Void + let onCompletion: @MainActor (ImagePlaygroundBridgeResult) -> Void + let onCancellation: @MainActor () -> Void + let onLaunchFailure: @MainActor (ImagePlaygroundBridgeLaunchFailure) -> Void + let onDismissed: @MainActor () -> Void + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIViewController(context: Context) -> ImagePlaygroundPresentationAnchor { + let anchor = ImagePlaygroundPresentationAnchor() + anchor.view.backgroundColor = .clear + context.coordinator.driver.anchor = anchor + anchor.becameUsable = { [weak driver = context.coordinator.driver] in driver?.presentIfReady() } + return anchor + } + + func updateUIViewController(_ anchor: ImagePlaygroundPresentationAnchor, context: Context) { + let coordinator = context.coordinator + // Defer observable host callbacks outside SwiftUI's update transaction. + coordinator.updateTask?.cancel() + coordinator.updateTask = Task { @MainActor in + guard !Task.isCancelled else { return } + if isPresented { + coordinator.session.start( + request: request, + available: ImagePlaygroundBridgeAvailability.current == .available, + openingTimeout: openingTimeout, + onOpened: onOpened, + onCompletion: onCompletion, + onCancellation: onCancellation, + onFailure: onLaunchFailure, + onDismissed: { + isPresented = false + onDismissed() + } + ) + coordinator.driver.presentIfReady() + } else { + coordinator.session.cancel() + } + } + } + + static func dismantleUIViewController(_ anchor: ImagePlaygroundPresentationAnchor, coordinator: Coordinator) { + coordinator.updateTask?.cancel() + anchor.becameUsable = nil + coordinator.session.invalidate() + } + + @MainActor final class Coordinator { + let driver: ImagePlaygroundUIKitDriver + let session: ImagePlaygroundPresentationSession + var updateTask: Task? + + init() { + driver = ImagePlaygroundUIKitDriver() + session = ImagePlaygroundPresentationSession(driver: driver) + } + } + } +#endif diff --git a/Sources/IntelligenceBridge/ImagePlaygroundPresentationAnchor.swift b/Sources/IntelligenceBridge/ImagePlaygroundPresentationAnchor.swift new file mode 100644 index 0000000..520c763 --- /dev/null +++ b/Sources/IntelligenceBridge/ImagePlaygroundPresentationAnchor.swift @@ -0,0 +1,18 @@ +#if os(iOS) + import UIKit + + /// An owned presentation anchor; it never inspects SwiftUI's hosting-controller hierarchy. + @MainActor final class ImagePlaygroundPresentationAnchor: UIViewController { + var becameUsable: (() -> Void)? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + becameUsable?() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + becameUsable?() + } + } +#endif diff --git a/Sources/IntelligenceBridge/ImagePlaygroundPresentationDriver.swift b/Sources/IntelligenceBridge/ImagePlaygroundPresentationDriver.swift new file mode 100644 index 0000000..73f370e --- /dev/null +++ b/Sources/IntelligenceBridge/ImagePlaygroundPresentationDriver.swift @@ -0,0 +1,15 @@ +import Foundation + +/// The owned native controller boundary, replaceable in deterministic lifecycle tests. +@MainActor protocol ImagePlaygroundPresentationDriver: AnyObject { + var isOpen: Bool { get } + + func present( + request: ImagePlaygroundBridgeRequest, + onOpened: @escaping @MainActor () -> Void, + onResult: @escaping @MainActor (URL) -> Void, + onCancelled: @escaping @MainActor () -> Void + ) + + func dismiss(completion: @escaping @MainActor () -> Void) +} diff --git a/Sources/IntelligenceBridge/ImagePlaygroundPresentationSession.swift b/Sources/IntelligenceBridge/ImagePlaygroundPresentationSession.swift new file mode 100644 index 0000000..8d8e90c --- /dev/null +++ b/Sources/IntelligenceBridge/ImagePlaygroundPresentationSession.swift @@ -0,0 +1,158 @@ +import Foundation + +/// Serializes opening, terminal delivery, and completed dismissal for one native presentation. +@MainActor final class ImagePlaygroundPresentationSession { + enum Phase { + case idle + case opening + case editing + case closing + } + + private(set) var phase: Phase = .idle + private var attemptID: UUID? + private var deadlineTask: Task? + private var onOpened: @MainActor () -> Void = {} + private var onCompletion: @MainActor (ImagePlaygroundBridgeResult) -> Void = { _ in } + private var onCancellation: @MainActor () -> Void = {} + private var onFailure: @MainActor (ImagePlaygroundBridgeLaunchFailure) -> Void = { _ in } + private var onDismissed: @MainActor () -> Void = {} + private let driver: any ImagePlaygroundPresentationDriver + private let wait: @Sendable (Duration) async throws -> Void + + init( + driver: any ImagePlaygroundPresentationDriver, + wait: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + ) { + self.driver = driver + self.wait = wait + } + + /// Accepts a request once; callbacks are immutable until this attempt has fully dismissed. + func start( + request: ImagePlaygroundBridgeRequest, + available: Bool, + openingTimeout: Duration, + onOpened: @escaping @MainActor () -> Void, + onCompletion: @escaping @MainActor (ImagePlaygroundBridgeResult) -> Void, + onCancellation: @escaping @MainActor () -> Void, + onFailure: @escaping @MainActor (ImagePlaygroundBridgeLaunchFailure) -> Void, + onDismissed: @escaping @MainActor () -> Void + ) { + guard phase == .idle else { return } + let id = UUID() + attemptID = id + phase = .opening + self.onOpened = onOpened + self.onCompletion = onCompletion + self.onCancellation = onCancellation + self.onFailure = onFailure + self.onDismissed = onDismissed + IntelligenceBridgeLogging.emit(.opening) + guard available else { + finish(id: id, failure: .unavailable) + return + } + deadlineTask = Task { [weak self, wait] in + guard !Task.isCancelled else { return } + do { try await wait(openingTimeout) } catch { return } + guard !Task.isCancelled else { return } + self?.openingExpired(id: id) + } + driver.present( + request: request, + onOpened: { [weak self] in self?.opened(id: id) }, + onResult: { [weak self] url in self?.completed(url: url, id: id) }, + onCancelled: { [weak self] in self?.cancelled(id: id) } + ) + } + + /// Cancels a requested presentation, including one still waiting for a window. + func cancel() { + guard let id = attemptID else { return } + cancelled(id: id) + } + + /// Releases native presentation when its SwiftUI anchor is removed, suppressing host callbacks. + func invalidate() { + if phase != .idle { IntelligenceBridgeLogging.emit(.invalidated) } + deadlineTask?.cancel() + deadlineTask = nil + attemptID = nil + phase = .idle + clearCallbacks() + driver.dismiss(completion: {}) + } + + private func opened(id: UUID) { + guard attemptID == id, phase == .opening, driver.isOpen else { return } + phase = .editing + deadlineTask?.cancel() + deadlineTask = nil + IntelligenceBridgeLogging.emit(.opened) + onOpened() + } + + private func openingExpired(id: UUID) { + guard attemptID == id, phase == .opening else { return } + if driver.isOpen { + opened(id: id) + } else { + finish(id: id, failure: .timedOut) + } + } + + private func completed(url: URL, id: UUID) { + guard acceptsTerminal(id) else { return } + phase = .closing + deadlineTask?.cancel() + // The host must consume the temporary URL before the native delegate returns. + IntelligenceBridgeLogging.emit(.completed) + onCompletion(ImagePlaygroundBridgeResult(temporaryURL: url)) + dismiss(id: id, failure: nil) + } + + private func cancelled(id: UUID) { + guard acceptsTerminal(id) else { return } + phase = .closing + deadlineTask?.cancel() + IntelligenceBridgeLogging.emit(.cancelled) + onCancellation() + dismiss(id: id, failure: nil) + } + + private func finish(id: UUID, failure: ImagePlaygroundBridgeLaunchFailure) { + guard acceptsTerminal(id) else { return } + phase = .closing + deadlineTask?.cancel() + IntelligenceBridgeLogging.emit(.launchFailed(failure)) + dismiss(id: id, failure: failure) + } + + private func acceptsTerminal(_ id: UUID) -> Bool { + attemptID == id && (phase == .opening || phase == .editing) + } + + private func dismiss(id: UUID, failure: ImagePlaygroundBridgeLaunchFailure?) { + driver.dismiss { [weak self] in + guard let self, self.attemptID == id, self.phase == .closing else { return } + self.attemptID = nil + self.deadlineTask = nil + let notifyFailure = self.onFailure + let notifyDismissed = self.onDismissed + self.clearCallbacks() + self.phase = .idle + IntelligenceBridgeLogging.emit(.dismissed) + if let failure { notifyFailure(failure) } + notifyDismissed() + } + } + + private func clearCallbacks() { + onOpened = {} + onCompletion = { _ in } + onCancellation = {} + onFailure = { _ in } + onDismissed = {} + } +} diff --git a/Sources/IntelligenceBridge/ImagePlaygroundUIKitDriver.swift b/Sources/IntelligenceBridge/ImagePlaygroundUIKitDriver.swift new file mode 100644 index 0000000..608249a --- /dev/null +++ b/Sources/IntelligenceBridge/ImagePlaygroundUIKitDriver.swift @@ -0,0 +1,114 @@ +#if os(iOS) + import ImagePlayground + import UIKit + + /// Owns the native controller and reports only public UIKit presentation lifecycle evidence. + @MainActor + final class ImagePlaygroundUIKitDriver: NSObject, ImagePlaygroundPresentationDriver, + ImagePlaygroundViewController.Delegate, UIAdaptivePresentationControllerDelegate + { + weak var anchor: ImagePlaygroundPresentationAnchor? + private var controller: ImagePlaygroundViewController? + private var requested = false + private var presenting = false + private var closing = false + private var dismissalCompletions: [@MainActor () -> Void] = [] + private var onOpened: @MainActor () -> Void = {} + private var onResult: @MainActor (URL) -> Void = { _ in } + private var onCancelled: @MainActor () -> Void = {} + + var isOpen: Bool { + guard let controller else { return false } + return controller.presentingViewController != nil && controller.viewIfLoaded?.window != nil + && !controller.isBeingPresented && !controller.isBeingDismissed + } + + func present( + request: ImagePlaygroundBridgeRequest, + onOpened: @escaping @MainActor () -> Void, + onResult: @escaping @MainActor (URL) -> Void, + onCancelled: @escaping @MainActor () -> Void + ) { + guard controller == nil else { return } + let editor = ImagePlaygroundViewController() + editor.concepts = request.concepts.map { concept in + switch concept { + case .extracted(let text, let title): .extracted(from: text, title: title) + case .text(let text): .text(text) + } + } + editor.delegate = self + controller = editor + requested = true + self.onOpened = onOpened + self.onResult = onResult + self.onCancelled = onCancelled + presentIfReady() + } + + /// Waits for the owned anchor to attach; the session deadline bounds that wait. + func presentIfReady() { + guard requested, !presenting, !closing, let controller, let anchor, + anchor.viewIfLoaded?.window != nil + else { return } + presenting = true + anchor.present(controller, animated: true) { [weak self, weak controller] in + guard let self, let controller, self.controller === controller, !self.closing else { return } + if self.isOpen { self.onOpened() } + } + controller.presentationController?.delegate = self + } + + /// Completes after any in-progress transition and the owned editor's dismissal. + func dismiss(completion: @escaping @MainActor () -> Void) { + dismissalCompletions.append(completion) + guard !closing else { return } + closing = true + requested = false + onOpened = {} + onResult = { _ in } + onCancelled = {} + controller?.delegate = nil + if let transition = controller?.transitionCoordinator, + transition.animate(alongsideTransition: nil, completion: { [weak self] _ in self?.dismissController() }) + { + return + } + dismissController() + } + + func imagePlaygroundViewController( + _ imagePlaygroundViewController: ImagePlaygroundViewController, didCreateImageAt imageURL: URL + ) { + guard controller === imagePlaygroundViewController, !closing else { return } + onResult(imageURL) + } + + func imagePlaygroundViewControllerDidCancel(_ imagePlaygroundViewController: ImagePlaygroundViewController) { + guard controller === imagePlaygroundViewController, !closing else { return } + onCancelled() + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard presentationController.presentedViewController === controller, !closing else { return } + onCancelled() + } + + private func dismissController() { + guard let controller, controller.presentingViewController != nil else { + finishDismissal() + return + } + controller.dismiss(animated: true) { [weak self] in self?.finishDismissal() } + } + + private func finishDismissal() { + controller = nil + presenting = false + closing = false + let completions = dismissalCompletions + dismissalCompletions.removeAll() + for completion in completions { completion() } + } + } +#endif diff --git a/Sources/IntelligenceBridge/IntelligenceBridge.docc/IntelligenceBridge.md b/Sources/IntelligenceBridge/IntelligenceBridge.docc/IntelligenceBridge.md index bcceb0d..9fbb084 100644 --- a/Sources/IntelligenceBridge/IntelligenceBridge.docc/IntelligenceBridge.md +++ b/Sources/IntelligenceBridge/IntelligenceBridge.docc/IntelligenceBridge.md @@ -8,11 +8,11 @@ Image Playground is the package's first capability. Use ``ImagePlaygroundBridgeA Image Playground supplies ``ImagePlaygroundBridgeResult/temporaryURL`` only as temporary output. The completion callback is the host application's ownership boundary: copy the file into durable, application-owned storage before returning. IntelligenceBridge never persists images, reads a photo library, synchronizes media, or owns product-specific prompts. -For Foundation Models, use ``FoundationModelBridgeAvailability/current`` to decide whether to offer model-backed behavior. -On supported platforms, importing IntelligenceBridge also exposes Apple's -[`FoundationModels`](https://developer.apple.com/documentation/foundationmodels) APIs, including `SystemLanguageModel`, -`LanguageModelSession`, `@Generable`, and `@Guide`. The host application owns prompts, sessions, generated values, and -product policy; IntelligenceBridge does not wrap Apple's session API or define a generic language-model abstraction. +For iOS hosts that need launch recovery, `imagePlaygroundBridgePresentation` adds confirmed opening, launch failure, and completed-dismissal callbacks through an owned UIKit presentation anchor. Its default 12-second opening deadline includes waiting for a window, rechecks actual presentation before reporting timeout, and stops permanently for that attempt once the controller opens. It does not measure the readiness of Apple's internal generation interface or limit editing. The existing cross-platform sheet API remains unchanged. + +Consume completion results synchronously. Completion or cancellation is delivered before native dismissal; launch failure is delivered after cleanup. `onDismissed` follows each terminal outcome and sets the presentation binding to false. Keep the modifier mounted until dismissal completes so the host can then remove its loading screen or present a recovery alert. Removing the anchor invalidates pending callbacks and dismisses the owned native editor. The host owns localized recovery UI and must preserve its draft independently of native presentation. + +For Foundation Models, use ``FoundationModelBridgeAvailability/current`` to decide whether to offer model-backed behavior. On supported platforms, importing IntelligenceBridge also exposes Apple's [`FoundationModels`](https://developer.apple.com/documentation/foundationmodels) APIs, including `SystemLanguageModel`, `LanguageModelSession`, `@Generable`, and `@Guide`. The host application owns prompts, sessions, generated values, and product policy; IntelligenceBridge does not wrap Apple's session API or define a generic language-model abstraction. ## Topics @@ -25,6 +25,7 @@ product policy; IntelligenceBridge does not wrap Apple's session API or define a ### Presentation results - ``ImagePlaygroundBridgeResult`` +- ``ImagePlaygroundBridgeLaunchFailure`` ### Foundation Models diff --git a/Sources/IntelligenceBridge/IntelligenceBridgeLogging.swift b/Sources/IntelligenceBridge/IntelligenceBridgeLogging.swift index ec6a457..d3ad778 100644 --- a/Sources/IntelligenceBridge/IntelligenceBridgeLogging.swift +++ b/Sources/IntelligenceBridge/IntelligenceBridgeLogging.swift @@ -7,6 +7,11 @@ enum IntelligenceBridgeLogging { enum Event: Equatable { case cancelled case completed + case dismissed + case invalidated + case launchFailed(ImagePlaygroundBridgeLaunchFailure) + case opened + case opening var message: String { switch self { @@ -14,12 +19,26 @@ enum IntelligenceBridgeLogging { "\(emoji) playground | result=cancelled" case .completed: "\(emoji) playground | result=completed" + case .dismissed: + "\(emoji) playground | result=dismissed" + case .invalidated: + "\(emoji) playground | result=invalidated" + case .launchFailed(let reason): + "\(emoji) playground | result=launch-failed reason=\(reason == .timedOut ? "timed-out" : "unavailable")" + case .opened: + "\(emoji) playground | result=opened" + case .opening: + "\(emoji) playground | result=opening" } } } static func emit(_ event: Event) { - AppLogger(subsystem: subsystem, category: "presentation") - .log(level: .debug, event.message) + let logger = AppLogger(subsystem: subsystem, category: "presentation") + if case .launchFailed = event { + logger.log(level: .error, event.message) + } else { + logger.log(level: .debug, event.message) + } } } diff --git a/Sources/IntelligenceBridge/View+ImagePlaygroundPresentation.swift b/Sources/IntelligenceBridge/View+ImagePlaygroundPresentation.swift new file mode 100644 index 0000000..ec6e9a2 --- /dev/null +++ b/Sources/IntelligenceBridge/View+ImagePlaygroundPresentation.swift @@ -0,0 +1,33 @@ +#if os(iOS) + public import SwiftUI + + extension View { + /// Presents the native editor with confirmed opening and completed-dismissal callbacks. + /// + /// The opening deadline includes waiting for the view to enter a window, and stops as soon as + /// UIKit confirms presentation. It never limits editing or image generation. Consume the result's + /// temporary file synchronously in `onCompletion`, before the native editor is dismissed. + /// Completion/cancellation is followed by `onDismissed`; launch failures are delivered after native + /// cleanup, immediately before `onDismissed`. Keep this modifier mounted until dismissal finishes. + @MainActor public func imagePlaygroundBridgePresentation( + isPresented: Binding, + request: ImagePlaygroundBridgeRequest, + openingTimeout: Duration = .seconds(12), + onOpened: @escaping @MainActor () -> Void, + onCompletion: @escaping @MainActor (ImagePlaygroundBridgeResult) -> Void, + onCancellation: @escaping @MainActor () -> Void, + onLaunchFailure: @escaping @MainActor (ImagePlaygroundBridgeLaunchFailure) -> Void, + onDismissed: @escaping @MainActor () -> Void + ) -> some View { + background { + ImagePlaygroundPresentationAdapter( + isPresented: isPresented, request: request, openingTimeout: openingTimeout, + onOpened: onOpened, onCompletion: onCompletion, onCancellation: onCancellation, + onLaunchFailure: onLaunchFailure, onDismissed: onDismissed + ) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + } +#endif diff --git a/Tests/IntelligenceBridgeTests/ImagePlaygroundPresentationSessionTests.swift b/Tests/IntelligenceBridgeTests/ImagePlaygroundPresentationSessionTests.swift new file mode 100644 index 0000000..809ca72 --- /dev/null +++ b/Tests/IntelligenceBridgeTests/ImagePlaygroundPresentationSessionTests.swift @@ -0,0 +1,185 @@ +import Foundation +import Testing + +@testable import IntelligenceBridge + +@Suite(.timeLimit(.minutes(1))) @MainActor +struct ImagePlaygroundPresentationSessionTests { + @Test func unavailableReportsFailureOnlyAfterCleanup() { + let fixture = Fixture() + fixture.start(available: false) + #expect(fixture.session.phase == .closing) + #expect(fixture.events.isEmpty) + fixture.driver.finishDismissal() + #expect(fixture.events == ["unavailable", "dismissed"]) + #expect(fixture.driver.presentCount == 0) + } + + @Test func neverAttachedTimesOutAndWaitsForDismissal() async { + let fixture = Fixture() + fixture.start() + await fixture.clock.expireWhenScheduled() + while fixture.session.phase == .opening { await Task.yield() } + #expect(fixture.session.phase == .closing) + #expect(fixture.events.isEmpty) + fixture.driver.finishDismissal() + #expect(fixture.events == ["timedOut", "dismissed"]) + } + + @Test func confirmedOpeningDoesNotExpireDuringEditing() async { + let fixture = Fixture() + fixture.start() + await fixture.clock.waitUntilScheduled() + fixture.driver.isOpen = true + fixture.driver.opened() + await fixture.clock.expireWhenScheduled() + #expect(fixture.session.phase == .editing) + #expect(fixture.events == ["opened"]) + fixture.session.cancel() + fixture.driver.finishDismissal() + #expect(fixture.events == ["opened", "cancelled", "dismissed"]) + } + + @Test func deadlineRechecksActuallyOpenedController() async { + let fixture = Fixture() + fixture.start() + fixture.driver.isOpen = true + await fixture.clock.expireWhenScheduled() + while fixture.session.phase == .opening { await Task.yield() } + #expect(fixture.session.phase == .editing) + #expect(fixture.events == ["opened"]) + fixture.session.invalidate() + } + + @Test func completionBeforeOpeningIsConsumedBeforeDismissalAndOnlyOnce() async { + let fixture = Fixture() + fixture.start() + await fixture.clock.waitUntilScheduled() + fixture.driver.result(URL(filePath: "/tmp/result")) + fixture.driver.result(URL(filePath: "/tmp/duplicate")) + fixture.driver.cancelled() + await fixture.clock.expireWhenScheduled() + #expect(fixture.events == ["completed"]) + #expect(fixture.session.phase == .closing) + #expect(fixture.driver.dismissCount == 1) + fixture.driver.finishDismissal() + #expect(fixture.events == ["completed", "dismissed"]) + } + + @Test func cancellationBeforeOpeningWinsOverDeadline() async { + let fixture = Fixture() + fixture.start() + await fixture.clock.waitUntilScheduled() + fixture.session.cancel() + fixture.driver.isOpen = true + fixture.driver.opened() + await fixture.clock.expireWhenScheduled() + fixture.driver.finishDismissal() + #expect(fixture.events == ["cancelled", "dismissed"]) + } + + @Test func staleCallbacksCannotTerminateNewAttempt() { + let fixture = Fixture() + fixture.start() + let oldResult = fixture.driver.result + let oldCancel = fixture.driver.cancelled + let oldOpen = fixture.driver.opened + fixture.session.cancel() + fixture.driver.finishDismissal() + fixture.events.removeAll() + fixture.start() + oldResult(URL(filePath: "/tmp/stale")) + oldCancel() + oldOpen() + #expect(fixture.session.phase == .opening) + #expect(fixture.events.isEmpty) + fixture.session.invalidate() + } + + @Test func removingAnchorSuppressesCallbacksAndCleansUp() { + let fixture = Fixture() + fixture.start() + let oldResult = fixture.driver.result + fixture.session.invalidate() + oldResult(URL(filePath: "/tmp/stale")) + fixture.driver.finishDismissal() + #expect(fixture.events.isEmpty) + #expect(fixture.session.phase == .idle) + } + + @MainActor private final class Fixture { + let driver = Driver() + let clock = OpeningClock() + let session: ImagePlaygroundPresentationSession + var events: [String] = [] + + init() { + let clock = clock + session = ImagePlaygroundPresentationSession(driver: driver, wait: { _ in await clock.wait() }) + } + + func start(available: Bool = true) { + session.start( + request: ImagePlaygroundBridgeRequest(concepts: [.text("prompt")]), available: available, + openingTimeout: .seconds(12), + onOpened: { [unowned self] in events.append("opened") }, + onCompletion: { [unowned self] _ in events.append("completed") }, + onCancellation: { [unowned self] in events.append("cancelled") }, + onFailure: { [unowned self] reason in events.append(reason == .timedOut ? "timedOut" : "unavailable") }, + onDismissed: { [unowned self] in events.append("dismissed") } + ) + } + } + + @MainActor private final class Driver: ImagePlaygroundPresentationDriver { + var isOpen = false + var presentCount = 0 + var dismissCount = 0 + var opened: @MainActor () -> Void = {} + var result: @MainActor (URL) -> Void = { _ in } + var cancelled: @MainActor () -> Void = {} + var dismissal: @MainActor () -> Void = {} + + func present( + request: ImagePlaygroundBridgeRequest, + onOpened: @escaping @MainActor () -> Void, + onResult: @escaping @MainActor (URL) -> Void, + onCancelled: @escaping @MainActor () -> Void + ) { + presentCount += 1 + opened = onOpened + result = onResult + cancelled = onCancelled + } + + func dismiss(completion: @escaping @MainActor () -> Void) { + dismissCount += 1 + dismissal = completion + } + + func finishDismissal() { + isOpen = false + let callback = dismissal + dismissal = {} + callback() + } + } + + private actor OpeningClock { + private var continuation: CheckedContinuation? + + func wait() async { + await withCheckedContinuation { continuation = $0 } + } + + func waitUntilScheduled() async { + while continuation == nil { await Task.yield() } + } + + func expireWhenScheduled() async { + await waitUntilScheduled() + continuation?.resume() + continuation = nil + } + } +} diff --git a/Tests/IntelligenceBridgeTests/IntelligenceBridgeTests.swift b/Tests/IntelligenceBridgeTests/IntelligenceBridgeTests.swift index 740fe56..d84d077 100644 --- a/Tests/IntelligenceBridgeTests/IntelligenceBridgeTests.swift +++ b/Tests/IntelligenceBridgeTests/IntelligenceBridgeTests.swift @@ -36,4 +36,14 @@ func resultsPreserveTemporaryURL() { func loggingMessagesArePrivacySafe() { #expect(IntelligenceBridgeLogging.Event.cancelled.message == "🧠 playground | result=cancelled") #expect(IntelligenceBridgeLogging.Event.completed.message == "🧠 playground | result=completed") + #expect(IntelligenceBridgeLogging.Event.opening.message == "🧠 playground | result=opening") + #expect(IntelligenceBridgeLogging.Event.opened.message == "🧠 playground | result=opened") + #expect(IntelligenceBridgeLogging.Event.dismissed.message == "🧠 playground | result=dismissed") + #expect(IntelligenceBridgeLogging.Event.invalidated.message == "🧠 playground | result=invalidated") + #expect( + IntelligenceBridgeLogging.Event.launchFailed(.timedOut).message + == "🧠 playground | result=launch-failed reason=timed-out") + #expect( + IntelligenceBridgeLogging.Event.launchFailed(.unavailable).message + == "🧠 playground | result=launch-failed reason=unavailable") } From c4d9b3d65eb77be31bbf451ad3998724145d08f8 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Mon, 19 Oct 2026 12:48:43 +0200 Subject: [PATCH 3/3] Reconcile pending native presentation and retain finite cleanup --- README.md | 2 + .../ImagePlaygroundPresentationAnchor.swift | 8 +- ...magePlaygroundPresentationOperations.swift | 25 ++ .../ImagePlaygroundUIKitDriver.swift | 227 ++++++++++-------- .../ImagePlaygroundUIKitOperations.swift | 81 +++++++ .../IntelligenceBridge.md | 2 + .../IntelligenceBridgeLogging.swift | 8 +- .../ImagePlaygroundUIKitDriverTests.swift | 188 +++++++++++++++ .../IntelligenceBridgeTests.swift | 1 + 9 files changed, 442 insertions(+), 100 deletions(-) create mode 100644 Sources/IntelligenceBridge/ImagePlaygroundPresentationOperations.swift create mode 100644 Sources/IntelligenceBridge/ImagePlaygroundUIKitOperations.swift create mode 100644 Tests/IntelligenceBridgeTests/ImagePlaygroundUIKitDriverTests.swift diff --git a/README.md b/README.md index e233f55..879fcd4 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,5 @@ API documentation is published with DocC after a GitHub release. See the [Intell - Xcode 27 - Apple platform versions shown in the badge above - Swift Package Manager + +Cleanup reconciles public UIKit state for up to two seconds after closing begins, retaining its owner independently of the SwiftUI host. A late association is dismissed, and observed detachment can finish cleanup even if UIKit omits a completion callback. UIKit has no definitive rejection callback: a never-associated editor is considered closed after this reconciliation window, but an editor that remains attached is never falsely reported dismissed. Permanently stuck system presentation machinery cannot guarantee bounded recovery; the bridge does not dismiss an unrelated containing sheet. diff --git a/Sources/IntelligenceBridge/ImagePlaygroundPresentationAnchor.swift b/Sources/IntelligenceBridge/ImagePlaygroundPresentationAnchor.swift index 520c763..9bff5c9 100644 --- a/Sources/IntelligenceBridge/ImagePlaygroundPresentationAnchor.swift +++ b/Sources/IntelligenceBridge/ImagePlaygroundPresentationAnchor.swift @@ -4,15 +4,17 @@ /// An owned presentation anchor; it never inspects SwiftUI's hosting-controller hierarchy. @MainActor final class ImagePlaygroundPresentationAnchor: UIViewController { var becameUsable: (() -> Void)? + private(set) var hasAppeared = false override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + hasAppeared = true becameUsable?() } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - becameUsable?() + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + hasAppeared = false } } #endif diff --git a/Sources/IntelligenceBridge/ImagePlaygroundPresentationOperations.swift b/Sources/IntelligenceBridge/ImagePlaygroundPresentationOperations.swift new file mode 100644 index 0000000..430b564 --- /dev/null +++ b/Sources/IntelligenceBridge/ImagePlaygroundPresentationOperations.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Observable state of the owned editor, without relying on presentation callback delivery. +struct ImagePlaygroundPresentationSnapshot { + var anchorIsReady = false + var associated = false + var attached = false + var transitioning = false + + var isOpen: Bool { associated && attached && !transitioning } + var isDetached: Bool { !associated && !attached && !transitioning } +} + +/// Internal UIKit operations seam; tests can control transitions without image generation. +@MainActor protocol ImagePlaygroundPresentationOperations: AnyObject { + var snapshot: ImagePlaygroundPresentationSnapshot { get } + func prepare( + request: ImagePlaygroundBridgeRequest, + onResult: @escaping @MainActor (URL) -> Void, + onCancelled: @escaping @MainActor () -> Void + ) + func present(completion: @escaping @MainActor () -> Void) + func dismiss(animated: Bool, completion: @escaping @MainActor () -> Void) + func reset() +} diff --git a/Sources/IntelligenceBridge/ImagePlaygroundUIKitDriver.swift b/Sources/IntelligenceBridge/ImagePlaygroundUIKitDriver.swift index 608249a..3b3db3e 100644 --- a/Sources/IntelligenceBridge/ImagePlaygroundUIKitDriver.swift +++ b/Sources/IntelligenceBridge/ImagePlaygroundUIKitDriver.swift @@ -1,114 +1,151 @@ -#if os(iOS) - import ImagePlayground - import UIKit +import Foundation - /// Owns the native controller and reports only public UIKit presentation lifecycle evidence. - @MainActor - final class ImagePlaygroundUIKitDriver: NSObject, ImagePlaygroundPresentationDriver, - ImagePlaygroundViewController.Delegate, UIAdaptivePresentationControllerDelegate - { - weak var anchor: ImagePlaygroundPresentationAnchor? - private var controller: ImagePlaygroundViewController? - private var requested = false - private var presenting = false - private var closing = false - private var dismissalCompletions: [@MainActor () -> Void] = [] - private var onOpened: @MainActor () -> Void = {} - private var onResult: @MainActor (URL) -> Void = { _ in } - private var onCancelled: @MainActor () -> Void = {} +/// Reconciles owned-controller observations even when UIKit omits a completion callback. +@MainActor final class ImagePlaygroundUIKitDriver: ImagePlaygroundPresentationDriver { + enum State { case idle, waitingForAnchor, presenting, opened, closing, finished } - var isOpen: Bool { - guard let controller else { return false } - return controller.presentingViewController != nil && controller.viewIfLoaded?.window != nil - && !controller.isBeingPresented && !controller.isBeingDismissed - } + let operations: any ImagePlaygroundPresentationOperations + private(set) var state = State.idle + private var attempt = UUID() + private var presentationInvoked = false + private var presentationCompleted = false + private var observedAssociation = false + private var dismissalRequested = false + private var cleanupTicks = 0 + private var cleanupTask: Task? + private let wait: @Sendable () async -> Void + private var dismissalCompletions: [@MainActor () -> Void] = [] + private var onOpened: @MainActor () -> Void = {} + private var onResult: @MainActor (URL) -> Void = { _ in } + private var onCancelled: @MainActor () -> Void = {} + + init( + operations: any ImagePlaygroundPresentationOperations, + wait: @escaping @Sendable () async -> Void = { try? await Task.sleep(for: .milliseconds(100)) } + ) { + self.operations = operations + self.wait = wait + } + + var isOpen: Bool { operations.snapshot.isOpen } - func present( - request: ImagePlaygroundBridgeRequest, - onOpened: @escaping @MainActor () -> Void, - onResult: @escaping @MainActor (URL) -> Void, - onCancelled: @escaping @MainActor () -> Void - ) { - guard controller == nil else { return } - let editor = ImagePlaygroundViewController() - editor.concepts = request.concepts.map { concept in - switch concept { - case .extracted(let text, let title): .extracted(from: text, title: title) - case .text(let text): .text(text) - } + func present( + request: ImagePlaygroundBridgeRequest, + onOpened: @escaping @MainActor () -> Void, + onResult: @escaping @MainActor (URL) -> Void, + onCancelled: @escaping @MainActor () -> Void + ) { + guard state == .idle || state == .finished else { return } + attempt = UUID() + let currentAttempt = attempt + presentationInvoked = false + presentationCompleted = false + observedAssociation = false + dismissalRequested = false + cleanupTicks = 0 + state = .waitingForAnchor + self.onOpened = onOpened + self.onResult = onResult + self.onCancelled = onCancelled + operations.prepare( + request: request, + onResult: { [weak self] url in + guard let self, self.attempt == currentAttempt, self.acceptsEditorCallbacks else { return } + self.onResult(url) + }, + onCancelled: { [weak self] in + guard let self, self.attempt == currentAttempt, self.acceptsEditorCallbacks else { return } + self.onCancelled() } - editor.delegate = self - controller = editor - requested = true - self.onOpened = onOpened - self.onResult = onResult - self.onCancelled = onCancelled - presentIfReady() - } + ) + presentIfReady() + } - /// Waits for the owned anchor to attach; the session deadline bounds that wait. - func presentIfReady() { - guard requested, !presenting, !closing, let controller, let anchor, - anchor.viewIfLoaded?.window != nil - else { return } - presenting = true - anchor.present(controller, animated: true) { [weak self, weak controller] in - guard let self, let controller, self.controller === controller, !self.closing else { return } - if self.isOpen { self.onOpened() } + private var acceptsEditorCallbacks: Bool { state == .presenting || state == .opened } + + func presentIfReady() { + guard state == .waitingForAnchor, operations.snapshot.anchorIsReady else { return } + state = .presenting + presentationInvoked = true + let currentAttempt = attempt + operations.present { [weak self] in + guard let self, self.attempt == currentAttempt else { return } + self.presentationCompleted = true + if self.state == .closing { + self.reconcileCleanup() + } else if self.state == .presenting, self.isOpen { + self.state = .opened + self.onOpened() } - controller.presentationController?.delegate = self } + } - /// Completes after any in-progress transition and the owned editor's dismissal. - func dismiss(completion: @escaping @MainActor () -> Void) { - dismissalCompletions.append(completion) - guard !closing else { return } - closing = true - requested = false - onOpened = {} - onResult = { _ in } - onCancelled = {} - controller?.delegate = nil - if let transition = controller?.transitionCoordinator, - transition.animate(alongsideTransition: nil, completion: { [weak self] _ in self?.dismissController() }) - { - return + /// A finite task owns cleanup independently of the SwiftUI coordinator and UIKit callbacks. + func dismiss(completion: @escaping @MainActor () -> Void) { + if state == .finished || state == .idle { + completion() + return + } + dismissalCompletions.append(completion) + guard state != .closing else { return } + state = .closing + onOpened = {} + onResult = { _ in } + onCancelled = {} + reconcileCleanup() + guard state == .closing else { return } + cleanupTask = Task { @MainActor [self] in + for tick in 1...20 { + guard state == .closing else { break } + await wait() + guard !Task.isCancelled, state == .closing else { break } + cleanupTicks = tick + reconcileCleanup() } - dismissController() + if state == .closing { IntelligenceBridgeLogging.emit(.cleanupStalled) } + cleanupTask = nil } + } - func imagePlaygroundViewController( - _ imagePlaygroundViewController: ImagePlaygroundViewController, didCreateImageAt imageURL: URL - ) { - guard controller === imagePlaygroundViewController, !closing else { return } - onResult(imageURL) + private func reconcileCleanup() { + guard state == .closing else { return } + let snapshot = operations.snapshot + observedAssociation = observedAssociation || snapshot.associated || snapshot.attached + if snapshot.isDetached { + // An unacknowledged presentation gets the full two-second reconciliation window. + let settled = + !presentationInvoked || cleanupTicks >= 20 + || (cleanupTicks > 0 && (presentationCompleted || observedAssociation)) + if settled { finishDismissal() } + return } - - func imagePlaygroundViewControllerDidCancel(_ imagePlaygroundViewController: ImagePlaygroundViewController) { - guard controller === imagePlaygroundViewController, !closing else { return } - onCancelled() + guard snapshot.associated else { return } + let escalate = cleanupTicks >= 10 + guard (!snapshot.transitioning && !dismissalRequested) || (escalate && cleanupTicks == 10) else { return } + dismissalRequested = true + let currentAttempt = attempt + operations.dismiss(animated: !escalate) { [weak self] in + guard let self, self.attempt == currentAttempt else { return } + self.reconcileCleanup() } + } - func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - guard presentationController.presentedViewController === controller, !closing else { return } - onCancelled() - } + private func finishDismissal() { + state = .finished + operations.reset() + let completions = dismissalCompletions + dismissalCompletions.removeAll() + for completion in completions { completion() } + } +} - private func dismissController() { - guard let controller, controller.presentingViewController != nil else { - finishDismissal() - return - } - controller.dismiss(animated: true) { [weak self] in self?.finishDismissal() } - } +#if os(iOS) + extension ImagePlaygroundUIKitDriver { + convenience init() { self.init(operations: ImagePlaygroundUIKitOperations()) } - private func finishDismissal() { - controller = nil - presenting = false - closing = false - let completions = dismissalCompletions - dismissalCompletions.removeAll() - for completion in completions { completion() } + var anchor: ImagePlaygroundPresentationAnchor? { + get { (operations as? ImagePlaygroundUIKitOperations)?.anchor } + set { (operations as? ImagePlaygroundUIKitOperations)?.anchor = newValue } } } #endif diff --git a/Sources/IntelligenceBridge/ImagePlaygroundUIKitOperations.swift b/Sources/IntelligenceBridge/ImagePlaygroundUIKitOperations.swift new file mode 100644 index 0000000..f757b20 --- /dev/null +++ b/Sources/IntelligenceBridge/ImagePlaygroundUIKitOperations.swift @@ -0,0 +1,81 @@ +#if os(iOS) + import ImagePlayground + import UIKit + + @MainActor + final class ImagePlaygroundUIKitOperations: NSObject, ImagePlaygroundPresentationOperations, + ImagePlaygroundViewController.Delegate, UIAdaptivePresentationControllerDelegate + { + weak var anchor: ImagePlaygroundPresentationAnchor? + private var controller: ImagePlaygroundViewController? + private var onResult: @MainActor (URL) -> Void = { _ in } + private var onCancelled: @MainActor () -> Void = {} + + var snapshot: ImagePlaygroundPresentationSnapshot { + ImagePlaygroundPresentationSnapshot( + anchorIsReady: anchor?.hasAppeared == true && anchor?.viewIfLoaded?.window != nil + && anchor?.isBeingDismissed == false && anchor?.presentedViewController == nil + && anchor?.transitionCoordinator == nil, + associated: controller != nil + && (controller?.presentingViewController != nil || anchor?.presentedViewController === controller), + attached: controller?.viewIfLoaded?.window != nil, + transitioning: controller?.isBeingPresented == true || controller?.isBeingDismissed == true + || controller?.transitionCoordinator != nil + ) + } + + func prepare( + request: ImagePlaygroundBridgeRequest, + onResult: @escaping @MainActor (URL) -> Void, + onCancelled: @escaping @MainActor () -> Void + ) { + let editor = ImagePlaygroundViewController() + editor.concepts = request.concepts.map { concept in + switch concept { + case .extracted(let text, let title): .extracted(from: text, title: title) + case .text(let text): .text(text) + } + } + self.onResult = onResult + self.onCancelled = onCancelled + editor.delegate = self + controller = editor + } + + func present(completion: @escaping @MainActor () -> Void) { + guard let controller, let anchor else { return } + anchor.present(controller, animated: true, completion: completion) + controller.presentationController?.delegate = self + } + + func dismiss(animated: Bool, completion: @escaping @MainActor () -> Void) { + controller?.delegate = nil + controller?.dismiss(animated: animated, completion: completion) + } + + func reset() { + controller?.delegate = nil + controller?.presentationController?.delegate = nil + controller = nil + onResult = { _ in } + onCancelled = {} + } + + func imagePlaygroundViewController( + _ imagePlaygroundViewController: ImagePlaygroundViewController, didCreateImageAt imageURL: URL + ) { + guard controller === imagePlaygroundViewController else { return } + onResult(imageURL) + } + + func imagePlaygroundViewControllerDidCancel(_ imagePlaygroundViewController: ImagePlaygroundViewController) { + guard controller === imagePlaygroundViewController else { return } + onCancelled() + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard presentationController.presentedViewController === controller else { return } + onCancelled() + } + } +#endif diff --git a/Sources/IntelligenceBridge/IntelligenceBridge.docc/IntelligenceBridge.md b/Sources/IntelligenceBridge/IntelligenceBridge.docc/IntelligenceBridge.md index 9fbb084..9b307c1 100644 --- a/Sources/IntelligenceBridge/IntelligenceBridge.docc/IntelligenceBridge.md +++ b/Sources/IntelligenceBridge/IntelligenceBridge.docc/IntelligenceBridge.md @@ -31,3 +31,5 @@ For Foundation Models, use ``FoundationModelBridgeAvailability/current`` to deci - ``FoundationModelBridgeAvailability`` - ``FoundationModelBridgeAvailability/Reason`` + +Cleanup reconciles public UIKit state for up to two seconds after closing begins, retaining its owner independently of the SwiftUI host. A late association is dismissed, and observed detachment can finish cleanup even if UIKit omits a completion callback. UIKit has no definitive rejection callback: a never-associated editor is considered closed after this reconciliation window, but an editor that remains attached is never falsely reported dismissed. Permanently stuck system presentation machinery cannot guarantee bounded recovery; the bridge does not dismiss an unrelated containing sheet. diff --git a/Sources/IntelligenceBridge/IntelligenceBridgeLogging.swift b/Sources/IntelligenceBridge/IntelligenceBridgeLogging.swift index d3ad778..dac5ff9 100644 --- a/Sources/IntelligenceBridge/IntelligenceBridgeLogging.swift +++ b/Sources/IntelligenceBridge/IntelligenceBridgeLogging.swift @@ -7,6 +7,7 @@ enum IntelligenceBridgeLogging { enum Event: Equatable { case cancelled case completed + case cleanupStalled case dismissed case invalidated case launchFailed(ImagePlaygroundBridgeLaunchFailure) @@ -17,6 +18,8 @@ enum IntelligenceBridgeLogging { switch self { case .cancelled: "\(emoji) playground | result=cancelled" + case .cleanupStalled: + "\(emoji) playground | result=cleanup-stalled" case .completed: "\(emoji) playground | result=completed" case .dismissed: @@ -35,9 +38,10 @@ enum IntelligenceBridgeLogging { static func emit(_ event: Event) { let logger = AppLogger(subsystem: subsystem, category: "presentation") - if case .launchFailed = event { + switch event { + case .launchFailed, .cleanupStalled: logger.log(level: .error, event.message) - } else { + default: logger.log(level: .debug, event.message) } } diff --git a/Tests/IntelligenceBridgeTests/ImagePlaygroundUIKitDriverTests.swift b/Tests/IntelligenceBridgeTests/ImagePlaygroundUIKitDriverTests.swift new file mode 100644 index 0000000..af3c16b --- /dev/null +++ b/Tests/IntelligenceBridgeTests/ImagePlaygroundUIKitDriverTests.swift @@ -0,0 +1,188 @@ +import Foundation +import Testing + +@testable import IntelligenceBridge + +@Suite(.timeLimit(.minutes(1))) @MainActor +struct ImagePlaygroundUIKitDriverTests { + @Test func unacknowledgedPresentationGetsBoundedCleanup() async { + let f = Fixture() + f.start() + f.close() + #expect(f.completed == 0) + await f.clock.advance(20) + await f.waitForFinished() + #expect(f.completed == 1) + #expect(f.operations.dismissals.isEmpty) + } + + @Test func lateAssociationAfterCancellationIsDismissed() async { + let f = Fixture() + f.start() + let oldCompletion = f.operations.presentation + f.close() + f.operations.snapshot.associated = true + f.operations.snapshot.attached = true + await f.clock.advance(1) + while f.operations.dismissals.isEmpty { await Task.yield() } + #expect(f.opened == 0) + f.operations.snapshot = .init(anchorIsReady: true) + await f.clock.advance(1) + await f.waitForFinished() + oldCompletion() + #expect(f.completed == 1) + #expect(f.opened == 0) + } + + @Test func actualStateIsVisibleWithoutPresentationCompletion() { + let f = Fixture() + f.start() + f.operations.snapshot.associated = true + f.operations.snapshot.attached = true + #expect(f.driver.isOpen) + #expect(f.opened == 0) + } + + @Test func cleanupSurvivesCoordinatorOwnerRelease() async { + let clock = Clock() + let operations = Operations() + var driver: ImagePlaygroundUIKitDriver? = ImagePlaygroundUIKitDriver( + operations: operations, wait: { await clock.wait() } + ) + weak let retained = driver + driver?.present(request: request, onOpened: {}, onResult: { _ in }, onCancelled: {}) + operations.snapshot.associated = true + operations.snapshot.attached = true + operations.snapshot.transitioning = true + var completed = false + driver?.dismiss { completed = true } + driver = nil + #expect(retained != nil) + operations.snapshot.transitioning = false + await clock.advance(1) + while operations.dismissals.isEmpty { await Task.yield() } + operations.snapshot = .init(anchorIsReady: true) + await clock.advance(1) + while !completed { await Task.yield() } + while retained != nil { await Task.yield() } + #expect(operations.resetCount == 1) + } + + @Test func detachmentCompletesWithoutDismissalCallback() async { + let f = Fixture() + f.start() + f.operations.snapshot.associated = true + f.operations.snapshot.attached = true + f.operations.presentation() + f.close() + #expect(f.operations.dismissals == [true]) + f.operations.snapshot = .init(anchorIsReady: true) + await f.clock.advance(1) + await f.waitForFinished() + #expect(f.completed == 1) + } + + @Test func staleCompletionCannotOpenNewAttempt() async { + let f = Fixture() + f.start() + let oldCompletion = f.operations.presentation + let oldResult = f.operations.result + f.close() + await f.clock.advance(20) + await f.waitForFinished() + f.start() + f.operations.snapshot.associated = true + f.operations.snapshot.attached = true + oldCompletion() + oldResult(URL(filePath: "/tmp/stale")) + #expect(f.opened == 0) + #expect(f.results == 0) + #expect(f.driver.state == .presenting) + f.operations.presentation() + #expect(f.opened == 1) + } + + @Test func stuckControllerIsEscalatedButNeverFalselyReportedDismissed() async { + let f = Fixture() + f.start() + f.operations.snapshot.associated = true + f.operations.snapshot.attached = true + f.operations.snapshot.transitioning = true + f.close() + await f.clock.advance(20) + while f.operations.dismissals.isEmpty { await Task.yield() } + #expect(f.operations.dismissals == [false]) + #expect(f.completed == 0) + #expect(f.driver.state == .closing) + } + + @Test func waitsForConfirmedAnchorReadiness() { + let f = Fixture() + f.operations.snapshot.anchorIsReady = false + f.start() + #expect(f.operations.presentCount == 0) + f.operations.snapshot.anchorIsReady = true + f.driver.presentIfReady() + f.driver.presentIfReady() + #expect(f.operations.presentCount == 1) + } + + private var request: ImagePlaygroundBridgeRequest { .init(concepts: [.text("prompt")]) } + + @MainActor private final class Fixture { + let operations = Operations() + let clock = Clock() + let driver: ImagePlaygroundUIKitDriver + var opened = 0 + var completed = 0 + var results = 0 + init() { + let clock = clock + driver = ImagePlaygroundUIKitDriver(operations: operations, wait: { await clock.wait() }) + } + func start() { + driver.present( + request: .init(concepts: [.text("prompt")]), + onOpened: { [unowned self] in opened += 1 }, + onResult: { [unowned self] _ in results += 1 }, onCancelled: {} + ) + } + func close() { driver.dismiss { [unowned self] in completed += 1 } } + func waitForFinished() async { + while driver.state != .finished { await Task.yield() } + } + } + + @MainActor private final class Operations: ImagePlaygroundPresentationOperations { + var snapshot = ImagePlaygroundPresentationSnapshot(anchorIsReady: true) + var presentation: @MainActor () -> Void = {} + var result: @MainActor (URL) -> Void = { _ in } + var presentCount = 0 + var dismissals: [Bool] = [] + var resetCount = 0 + func prepare( + request: ImagePlaygroundBridgeRequest, + onResult: @escaping @MainActor (URL) -> Void, + onCancelled: @escaping @MainActor () -> Void + ) { result = onResult } + func present(completion: @escaping @MainActor () -> Void) { + presentCount += 1 + presentation = completion + } + func dismiss(animated: Bool, completion: @escaping @MainActor () -> Void) { dismissals.append(animated) } + func reset() { resetCount += 1 } + } + + private actor Clock { + private var continuation: CheckedContinuation? + func wait() async { await withCheckedContinuation { continuation = $0 } } + func advance(_ ticks: Int) async { + for _ in 0..