diff --git a/MOBILE.md b/MOBILE.md index ccc13e3f3d..7b4d565ce0 100644 --- a/MOBILE.md +++ b/MOBILE.md @@ -89,6 +89,16 @@ Implementation notes: - Active-profile deletion fallback is shell-owned: deleting the active profile promotes the first remaining profile, and deleting the final profile resets to a clean empty state. - The dashboard consumes this through the shared `window.fusionShell` connection APIs. +### Native Back Handling (Android Back + iOS Edge-Swipe-Back) + +Task-detail dismissal via native "back" (Android hardware Back / predictive-back gesture, +iOS edge-swipe-back, or plain browser swipe-back) converges on a single shared invariant: +the dashboard's `useNavigationHistory` nav-history stack. See `packages/mobile/README.md` +→ "Native Back Handling" for the full Android (`fusion:native-back`) vs. iOS (`popstate`) +routing details and the tracked post-`cap sync` patch scripts (`scripts/ +patch-android-manifest.ts`, `scripts/patch-ios-webview.ts`) that keep each platform's native +gesture delivery enabled across `cap sync` regenerations. + ### Planning Mode Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. On desktop/tablet the split view is unaffected. Once sessions are saved, mobile shows the session list as usual and the user can navigate between list and detail panes. @@ -186,3 +196,5 @@ Mobile package scripts (`packages/mobile/package.json`): - `dev:ios` - `dev:android` - `build:mobile` +- `patch:ios-webview` — idempotently enables the iOS WKWebView edge-swipe-back gesture in the generated `ios/App/App/AppDelegate.swift` (safe no-op if `ios/` doesn't exist yet) +- `capacitor:sync:after` — Capacitor's own post-`cap sync` hook; currently runs the iOS webview patch so `cap sync` regeneration can't silently drop the gesture opt-in diff --git a/packages/dashboard/app/__tests__/mobile-scripts.test.ts b/packages/dashboard/app/__tests__/mobile-scripts.test.ts index c16e85021f..51e6da5a88 100644 --- a/packages/dashboard/app/__tests__/mobile-scripts.test.ts +++ b/packages/dashboard/app/__tests__/mobile-scripts.test.ts @@ -43,4 +43,35 @@ describe("mobile pipeline scripts", () => { expect(packageJson.scripts?.["mobile:ios"] ?? "").toContain("ios"); expect(packageJson.scripts?.["mobile:android"] ?? "").toContain("android"); }); + + /* + FNXC:TaskDetailIOSSwipeBack 2026-07-05-12:10: + FN-7586: `WKWebView.allowsBackForwardNavigationGestures` defaults to false and no + capacitor.config toggle exists for it (confirmed against the installed @capacitor/cli + declarations), so the fix is a tracked post-`cap sync` patch script + (`packages/mobile/scripts/patch-ios-webview.ts`) wired into Capacitor's own + `capacitor:sync:after` npm hook — mirroring FN-7583's Android manifest-patch precedent — + so `cap sync` regeneration of the git-ignored `ios/` project can never silently drop the + gesture opt-in. This asserts the patch/wiring is present in tracked source; it fails on the + pre-fix tree (no patch script, no hook) and passes after. + */ + describe("iOS edge-swipe-back gesture patch wiring (FN-7586)", () => { + const mobilePackagePath = resolve(__dirname, "../../../mobile/package.json"); + const patchScriptPath = resolve(__dirname, "../../../mobile/scripts/patch-ios-webview.ts"); + + it("ships a tracked post-cap-sync iOS WKWebView patch script", () => { + const source = readFileSync(patchScriptPath, "utf8"); + + expect(source).toContain("allowsBackForwardNavigationGestures"); + expect(source).toContain("CAPBridgeViewController"); + }); + + it("wires the iOS webview patch into a Capacitor sync hook so cap sync cannot drop it", () => { + const mobilePackageJson = JSON.parse(readFileSync(mobilePackagePath, "utf8")) as WorkspacePackageJson; + const scripts = mobilePackageJson.scripts ?? {}; + + const syncAfterHook = scripts["capacitor:sync:after"] ?? ""; + expect(syncAfterHook).toContain("patch-ios-webview"); + }); + }); }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx index 6b14800011..4071908d83 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx @@ -802,4 +802,135 @@ describe("Task detail mobile swipe-back", () => { expect(window.history.pushState).not.toHaveBeenCalled(); expect(screen.queryByTestId("task-detail-modal")).toBeNull(); }); + + /* + FNXC:TaskDetailIOSSwipeBack 2026-07-05-12:10: + FN-7586 diagnosed the iOS edge-swipe-back gap as native-delivery-only: WKWebView's + `allowsBackForwardNavigationGestures` defaults to `false` and Capacitor never sets it, so + the gesture never fires at all today (fixed via `packages/mobile/scripts/ + patch-ios-webview.ts`, an AppDelegate patch wired into the `capacitor:sync:after` hook). + Once enabled, iOS drives ordinary WKWebView back-forward navigation, which is delivered to + the page as a `popstate` — the EXACT SAME seam desktop/browser swipe-back already uses. + This suite's existing `dispatchPopState` coverage above (board main-panel, list-mobile, + nested modal) therefore already proves the shared nav-history dismissal invariant for the + iOS gesture too, and required NO dashboard-side change. This describe block adds + iOS-labeled regression coverage (same `popstate` seam, explicitly named) plus the one gap + not yet covered above: the empty-stack fallback via `popstate` (no Fusion nav entry -> + safe no-op, matching how native Android Back already no-ops on an empty stack). + */ + describe("iOS edge-swipe-back (popstate seam)", () => { + it("dismisses the board main-panel task detail on iOS edge-swipe-back (popstate)", async () => { + const task = makeTask("FN-1", "iOS Board Detail"); + mockUseTasks.mockImplementation(() => ({ + tasks: [task], + createTask: mockCreateTask, + moveTask: vi.fn(), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + retryTask: vi.fn(), + updateTask: vi.fn(), + duplicateTask: vi.fn(), + archiveTask: vi.fn(), + unarchiveTask: vi.fn(), + archiveAllDone: vi.fn(), + refreshTasks: vi.fn(), + })); + + await renderAppAndWait("board-view"); + fireEvent.click(screen.getByTestId("open-task-FN-1")); + + await waitFor(() => { + expect(screen.getByTestId("task-detail-main-panel-content")).toBeInTheDocument(); + }); + + dispatchPopState({ navIndex: 0 }); + + await waitFor(() => { + expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull(); + expect(screen.getByTestId("board-view")).toBeInTheDocument(); + }); + }); + + it("dismisses the list-mobile task detail on iOS edge-swipe-back (popstate)", async () => { + const task = makeTask("FN-1", "iOS Mobile List Detail"); + mockUseTasks.mockImplementation(() => ({ + tasks: [task], + createTask: mockCreateTask, + moveTask: vi.fn(), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + retryTask: vi.fn(), + updateTask: vi.fn(), + duplicateTask: vi.fn(), + archiveTask: vi.fn(), + unarchiveTask: vi.fn(), + archiveAllDone: vi.fn(), + refreshTasks: vi.fn(), + })); + localStorage.setItem("kb-dashboard-view-mode", "project"); + localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list"); + + await renderAppAndWait("list-view"); + fireEvent.click(screen.getByTestId("list-open-FN-1")); + + await waitFor(() => { + expect(screen.getByTestId("task-detail-modal")).toBeInTheDocument(); + }); + + dispatchPopState({ navIndex: 0 }); + + await waitFor(() => { + expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + expect(screen.getByTestId("list-view")).toBeInTheDocument(); + }); + }); + + it("restores the previous modal detail when iOS edge-swipe-back (popstate) closes a nested task detail", async () => { + const task = makeTask("FN-1", "iOS Parent Task"); + mockUseTasks.mockImplementation(() => ({ + tasks: [task, makeTask("FN-2", "iOS Nested Task")], + createTask: mockCreateTask, + moveTask: vi.fn(), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + retryTask: vi.fn(), + updateTask: vi.fn(), + duplicateTask: vi.fn(), + archiveTask: vi.fn(), + unarchiveTask: vi.fn(), + archiveAllDone: vi.fn(), + refreshTasks: vi.fn(), + })); + localStorage.setItem("kb-dashboard-view-mode", "project"); + localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list"); + + await renderAppAndWait("list-view"); + fireEvent.click(screen.getByTestId("list-open-FN-1")); + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "iOS Parent Task" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("task-detail-open-nested")); + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "iOS Nested Task" })).toBeInTheDocument(); + }); + + dispatchPopState({ navIndex: 1 }); + + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "iOS Parent Task" })).toBeInTheDocument(); + }); + expect(screen.queryByRole("dialog", { name: "iOS Nested Task" })).toBeNull(); + }); + + it("safely no-ops on iOS edge-swipe-back (popstate) when Fusion owns no nav entry", async () => { + await renderAppAndWait("board-view"); + + expect(() => dispatchPopState(null)).not.toThrow(); + + await waitFor(() => { + expect(screen.getByTestId("board-view")).toBeInTheDocument(); + }); + }); + }); }); diff --git a/packages/mobile/README.md b/packages/mobile/README.md index 47925cc929..acbc994cdc 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -53,6 +53,42 @@ pnpm --filter @fusion/mobile patch:android-manifest - bridge reads (`getState`, `listProfiles`) plus connection-manager event dispatch - malformed/empty QR payload handling and unavailable-scanner fallback behavior +## Native Back Handling (Android Back + iOS Edge-Swipe-Back) + +Mobile task-detail dismissal converges on a SINGLE shared invariant regardless of how the +user triggers "back": the dashboard's `useNavigationHistory` nav-history stack +(`packages/dashboard/app/hooks/useNavigationHistory.ts`), which board main-panel, +list-mobile, modal, and nested task-detail surfaces already consume for desktop/browser +swipe-back via ordinary `popstate`. + +- **Android hardware Back button + gesture (FN-7583):** `AndroidBackButtonManager` in + `src/plugins/native-shell.ts` listens for `@capacitor/app`'s `backButton` event and + dispatches a cancelable `fusion:native-back` custom event; `useNavigationHistory` consumes + it to dismiss the top task-detail surface (falling back to native browser-history `back()` + or app exit when Fusion owns no nav entry). The Android 13+ predictive-back **gesture** + additionally requires the generated `AndroidManifest.xml` to opt into + `android:enableOnBackInvokedCallback="true"` for AndroidX's dispatcher to route the + gesture-completion callback to the same `backButton` listener — since `android/` is + generated by `cap sync` and git-ignored, this opt-in lives in a tracked post-`cap sync` + patch (`scripts/patch-android-manifest.ts`), wired into the `capacitor:sync:after` npm hook. +- **iOS edge-swipe-back (FN-7586):** iOS has **no** Capacitor `backButton` event, so it does + NOT go through `fusion:native-back`. Instead, the WKWebView edge-swipe-back gesture (once + enabled) drives ordinary back-forward navigation, which the browser delivers as a + `popstate` — the exact same seam desktop/browser swipe-back already uses, and that + `useNavigationHistory.handlePopState` already consumes correctly. The gesture itself is OFF + by default: `WKWebView.allowsBackForwardNavigationGestures` defaults to `false`, Capacitor's + `CAPBridgeViewController` never sets it, and no `capacitor.config.ts` `ios` toggle exists + for it. Since `ios/` is also generated by `cap sync` and git-ignored, this is enabled via a + tracked post-`cap sync` patch (`scripts/patch-ios-webview.ts`) that idempotently patches the + generated `AppDelegate.swift` to set `webView?.allowsBackForwardNavigationGestures = true` + once the storyboard's root `CAPBridgeViewController` has loaded — also wired into the same + `capacitor:sync:after` npm hook. Empty-stack behavior (no Fusion nav entry) is unaffected: + the gesture just performs ordinary WKWebView history navigation with no forced trap/exit. + +Both patch scripts are safe no-ops when their respective native platform directory +(`android/` / `ios/`) hasn't been added yet, and are idempotent across repeated `cap sync` +runs (see `src/__tests__/native-shell.test.ts`). + ## Push Notifications `PushNotificationManager` supports two complementary notification channels: diff --git a/packages/mobile/package.json b/packages/mobile/package.json index aecd168db0..fe473d5575 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -20,7 +20,8 @@ "dev:android": "tsx scripts/live-reload.ts --platform android", "build:mobile": "pnpm --filter @fusion/dashboard build && npx cap sync", "patch:android-manifest": "npx tsx scripts/patch-android-manifest.ts", - "capacitor:sync:after": "npx tsx scripts/patch-android-manifest.ts", + "patch:ios-webview": "npx tsx scripts/patch-ios-webview.ts", + "capacitor:sync:after": "npx tsx scripts/patch-android-manifest.ts && npx tsx scripts/patch-ios-webview.ts", "test": "vitest run --silent=passed-only --reporter=dot", "typecheck": "tsc --noEmit" }, diff --git a/packages/mobile/scripts/patch-ios-webview.ts b/packages/mobile/scripts/patch-ios-webview.ts new file mode 100644 index 0000000000..6f906b7394 --- /dev/null +++ b/packages/mobile/scripts/patch-ios-webview.ts @@ -0,0 +1,119 @@ +/** + * FNXC:TaskDetailIOSSwipeBack 2026-07-05-12:10: + * FN-7586: the iOS edge-swipe-back gesture (WKWebView interactive pop / back-forward + * navigation gesture) is a guaranteed no-op on a stock Capacitor 7 iOS build of this app. + * `WKWebView.allowsBackForwardNavigationGestures` defaults to `false` + * (https://developer.apple.com/documentation/webkit/wkwebview/1414995-allowsbackforwardnavigationgestu), + * `CAPBridgeViewController.prepareWebView` (in `@capacitor/ios`) sets several WKWebView + * properties from `capacitor.config.ts` (`allowsLinkPreview`, `scrollView.isScrollEnabled`, + * background color, content-inset behavior) but never touches this one, and the installed + * `@capacitor/cli`'s `ios` config block (see `declarations.d.ts`) exposes no first-class + * toggle for it either. Without the gesture enabled, iOS never even reaches + * `window.history.back()` / `popstate` — so the dashboard's shared nav-history dismissal + * stack (`useNavigationHistory.handlePopState`), which board/list/modal/nested task-detail + * surfaces already converge on for desktop/browser swipe-back, never gets a chance to run. + * + * The native `ios/` project is generated by `cap sync` (via `cap add ios`, which extracts + * `@capacitor/cli`'s bundled `ios-pods-template.tar.gz`) and is git-ignored (see AGENTS.md / + * `.gitignore`), so durable changes cannot live there directly — mirroring FN-7583's Android + * manifest-patch precedent (`patch-android-manifest.ts`), this script idempotently patches + * the generated `AppDelegate.swift` immediately after every sync, wired into Capacitor's own + * `capacitor:sync:after` npm-script hook (see `package.json`), which the Capacitor CLI runs + * automatically after `cap sync` (and therefore after `cap run ios` / `build:mobile` too). + * + * Patch target: the stock template has NO custom `CAPBridgeViewController` subclass — the + * generated `Main.storyboard` wires `customClass="CAPBridgeViewController"` directly onto the + * scene's view controller. `AppDelegate.didFinishLaunchingWithOptions` is therefore the + * stable, minimal patch point: by the time it runs, `UIApplicationMain` has already loaded + * the storyboard's root view controller into `window.rootViewController`. The patch casts it + * to `CAPBridgeViewController`, forces `loadViewIfNeeded()` so `webView` is non-nil (the + * bridge view controller creates its WKWebView lazily in `loadView()`), then sets + * `webView?.allowsBackForwardNavigationGestures = true`. + * + * This keeps a SINGLE dismissal invariant across platforms: once the gesture is delivered, + * iOS drives ordinary WKWebView back-forward navigation -> `popstate`, the exact same arm + * browser/desktop swipe-back already uses and that `useNavigationHistory.handlePopState` + * already consumes for board main-panel / list-mobile / modal / nested task-detail surfaces. + * iOS has no Capacitor `backButton` event (that's Android-only, see + * `src/plugins/native-shell.ts`'s `AndroidBackButtonManager`), so this does NOT add an iOS + * `fusion:native-back` emitter — it reuses the existing `popstate` arm of the shared + * invariant instead of introducing a second, divergent back-handling path. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const MARKER = "allowsBackForwardNavigationGestures"; + +const DID_FINISH_LAUNCHING_SIGNATURE = + /func application\(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\?\) -> Bool \{/; + +function buildInjectedSnippet(): string { + return ` + // FNXC:TaskDetailIOSSwipeBack 2026-07-05-12:10: + // FN-7586: enable the WKWebView edge-swipe-back gesture so it drives ordinary + // back-forward navigation (-> popstate), matching Android Back's fusion:native-back + // arm and desktop/browser swipe-back. See packages/mobile/scripts/patch-ios-webview.ts + // for the full rationale (idempotent — this line is the presence marker). + if let bridgeViewController = self.window?.rootViewController as? CAPBridgeViewController { + bridgeViewController.loadViewIfNeeded() + bridgeViewController.webView?.${MARKER} = true + } +`; +} + +export function patchAppDelegateSource(swift: string): { changed: boolean; swift: string } { + if (swift.includes(MARKER)) { + // Already opted in (idempotent — do not duplicate on repeat `cap sync` runs). + return { changed: false, swift }; + } + + const signatureMatch = swift.match(DID_FINISH_LAUNCHING_SIGNATURE); + if (!signatureMatch || signatureMatch.index === undefined) { + // Unrecognized AppDelegate shape (e.g. a hand-customized template) — do not guess. + return { changed: false, swift }; + } + + const insertAt = signatureMatch.index + signatureMatch[0].length; + const patched = swift.slice(0, insertAt) + buildInjectedSnippet() + swift.slice(insertAt); + return { changed: true, swift: patched }; +} + +export function resolveAppDelegatePath(mobilePackageDir: string): string { + return join(mobilePackageDir, "ios", "App", "App", "AppDelegate.swift"); +} + +export function patchIOSWebView(mobilePackageDir: string): { patched: boolean; skipped: boolean; path: string } { + const appDelegatePath = resolveAppDelegatePath(mobilePackageDir); + + if (!existsSync(appDelegatePath)) { + // No iOS platform added yet (e.g. Android-only sync) — safe no-op, not an error. + return { patched: false, skipped: true, path: appDelegatePath }; + } + + const original = readFileSync(appDelegatePath, "utf8"); + const { changed, swift } = patchAppDelegateSource(original); + + if (changed) { + writeFileSync(appDelegatePath, swift, "utf8"); + } + + return { patched: changed, skipped: false, path: appDelegatePath }; +} + +function isMainModule(): boolean { + return process.argv[1] === fileURLToPath(import.meta.url); +} + +if (isMainModule()) { + const mobilePackageDir = dirname(fileURLToPath(import.meta.url)).replace(/[/\\]scripts$/, ""); + const result = patchIOSWebView(mobilePackageDir); + + if (result.skipped) { + console.log(`[patch-ios-webview] no ios/ project at ${result.path}; skipping (nothing to patch yet)`); + } else if (result.patched) { + console.log(`[patch-ios-webview] enabled allowsBackForwardNavigationGestures in ${result.path}`); + } else { + console.log(`[patch-ios-webview] ${result.path} already enables allowsBackForwardNavigationGestures; no change`); + } +} diff --git a/packages/mobile/src/__tests__/native-shell.test.ts b/packages/mobile/src/__tests__/native-shell.test.ts index 623fa4f115..b261b755d9 100644 --- a/packages/mobile/src/__tests__/native-shell.test.ts +++ b/packages/mobile/src/__tests__/native-shell.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildMobileShellHandoff } from "../plugins/shell-handoff.js"; import { patchAndroidManifest, patchManifestSource } from "../../scripts/patch-android-manifest.js"; +import { patchAppDelegateSource, patchIOSWebView } from "../../scripts/patch-ios-webview.js"; type BackButtonListener = (event: { canGoBack: boolean }) => void; @@ -264,6 +265,7 @@ describe("MobileNativeShellBridge", () => { expect(capacitorState.exitApp).toHaveBeenCalledTimes(1); }); + /* /* FNXC:TaskDetailAndroidBack 2026-07-05-11:45: FN-7583 — the Android back GESTURE (predictive back / edge swipe, Android 13+) reached @@ -361,4 +363,124 @@ describe("MobileNativeShellBridge", () => { expect(xml).toContain('android:label="@string/app_name"'); }); }); + + /* + FNXC:TaskDetailIOSSwipeBack 2026-07-05-12:10: + FN-7586 — the iOS edge-swipe-back GESTURE (WKWebView interactive pop) is a no-op on a + stock Capacitor 7 iOS build: `WKWebView.allowsBackForwardNavigationGestures` defaults to + `false`, `CAPBridgeViewController.prepareWebView` never sets it, and no capacitor.config + toggle exists. The raw WKWebView gesture cannot be dispatched from a unit test, so these + tests drive the actual seam the fix introduces (`patch-ios-webview.ts`) and assert it + converges on the exact contract the popstate-based coverage in + `TaskDetail.swipe-back.test.tsx` already proves: once AppDelegate enables the gesture, iOS + drives ordinary WKWebView back-forward navigation -> `popstate` -> the shared nav-history + dismissal stack, with no iOS-specific `fusion:native-back` emitter introduced. This test + fails against the pre-fix tree (no `patch-ios-webview.ts` module, so the import above + throws) and passes after. + */ + describe("iOS Back: edge-swipe-back gesture opt-in (patch-ios-webview)", () => { + let workDir: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "fusion-fn7586-appdelegate-")); + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + function writeAppDelegate(swift: string): string { + const appDir = join(workDir, "ios", "App", "App"); + mkdirSync(appDir, { recursive: true }); + const appDelegatePath = join(appDir, "AppDelegate.swift"); + writeFileSync(appDelegatePath, swift, "utf8"); + return appDelegatePath; + } + + const baseAppDelegate = `import UIKit +import Capacitor + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + } + +} +`; + + it("enables the WKWebView edge-swipe-back gesture in the generated AppDelegate (the seam the gesture needs)", () => { + const appDelegatePath = writeAppDelegate(baseAppDelegate); + + const result = patchIOSWebView(workDir); + + expect(result.patched).toBe(true); + expect(result.skipped).toBe(false); + const patchedSwift = readFileSync(appDelegatePath, "utf8"); + expect(patchedSwift).toContain("as? CAPBridgeViewController"); + expect(patchedSwift).toContain("webView?.allowsBackForwardNavigationGestures = true"); + // Injected before the existing `return true` so the override still returns true. + expect(patchedSwift.indexOf("allowsBackForwardNavigationGestures")).toBeLessThan( + patchedSwift.indexOf("return true"), + ); + }); + + it("is idempotent across repeated cap sync runs (does not duplicate the patch)", () => { + writeAppDelegate(baseAppDelegate); + + const first = patchIOSWebView(workDir); + const second = patchIOSWebView(workDir); + + expect(first.patched).toBe(true); + expect(second.patched).toBe(false); + const appDelegatePath = join(workDir, "ios", "App", "App", "AppDelegate.swift"); + const swift = readFileSync(appDelegatePath, "utf8"); + expect(swift.match(/allowsBackForwardNavigationGestures/g)).toHaveLength(1); + }); + + it("leaves an already-patched AppDelegate untouched", () => { + const alreadyPatched = baseAppDelegate.replace( + "// Override point for customization after application launch.", + "// Override point for customization after application launch.\n self.window?.rootViewController.map { _ in }\n // allowsBackForwardNavigationGestures already set elsewhere", + ); + writeAppDelegate(alreadyPatched); + + const result = patchIOSWebView(workDir); + + expect(result.patched).toBe(false); + const swift = readFileSync(join(workDir, "ios", "App", "App", "AppDelegate.swift"), "utf8"); + expect(swift.match(/allowsBackForwardNavigationGestures/g)).toHaveLength(1); + }); + + it("no-ops safely (does not throw) when no ios/ project has been added yet", () => { + const result = patchIOSWebView(workDir); + + expect(result.skipped).toBe(true); + expect(result.patched).toBe(false); + }); + + it("patchAppDelegateSource preserves the rest of AppDelegate verbatim aside from the injected snippet", () => { + const { changed, swift } = patchAppDelegateSource(baseAppDelegate); + + expect(changed).toBe(true); + expect(swift).toContain("func applicationWillResignActive(_ application: UIApplication) {"); + expect(swift).toContain("class AppDelegate: UIResponder, UIApplicationDelegate {"); + }); + + it("does not change patchAppDelegateSource when the didFinishLaunchingWithOptions signature is unrecognized", () => { + const unrecognized = "import UIKit\nclass AppDelegate {}\n"; + + const { changed, swift } = patchAppDelegateSource(unrecognized); + + expect(changed).toBe(false); + expect(swift).toBe(unrecognized); + }); + }); });