Files
fusion/packages/mobile/scripts/patch-ios-webview.ts
gsxdsm da36094d74 FN-7586: add iOS edge-swipe-back gesture parity via AppDelegate patch
Enables the WKWebView interactive-pop (edge-swipe-back) gesture on iOS, matching the Android predictive-back parity already in place, by patching the generated AppDelegate.swift during cap sync.

- Add packages/mobile/scripts/patch-ios-webview.ts: patches AppDelegate.swift to cast the root view controller to CAPBridgeViewController and set webView?.allowsBackForwardNavigationGestures = true before the didFinishLaunchingWithOptions return, is idempotent, and no-ops safely when no ios/ project exists yet
- Wire capacitor:sync:after to run both patch-android-manifest.ts and patch-ios-webview.ts so cap sync keeps both native back-gesture opt-ins in sync; add patch:ios-webview script
- Add unit tests covering patch, idempotency, already-patched, missing-project, and source-preservation cases for the new iOS webview patch, alongside the existing Android manifest patch tests
- Add TaskDetail.swipe-back.test.tsx coverage proving the shared popstate-driven nav-history dismissal stack (no iOS-specific native-back emitter needed) already satisfies the gesture's dismissal contract
- Add mobile-scripts.test.ts dashboard coverage and update MOBILE.md / packages/mobile/README.md documenting the new iOS gesture opt-in

Files changed:
 MOBILE.md                                          |  12 ++
 .../dashboard/app/__tests__/mobile-scripts.test.ts |  31 +++++
 .../__tests__/TaskDetail.swipe-back.test.tsx       | 131 +++++++++++++++++++++
 packages/mobile/README.md                          |  36 ++++++
 packages/mobile/package.json                       |   3 +-
 packages/mobile/scripts/patch-ios-webview.ts       | 119 +++++++++++++++++++
 packages/mobile/src/__tests__/native-shell.test.ts | 122 +++++++++++++++++++
 7 files changed, 453 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7586

Fusion-Task-Lineage: 248092a1-de1b-49b6-94ef-70675a7b93a1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-05 12:24:12 -07:00

120 lines
6.3 KiB
TypeScript

/**
* 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`);
}
}