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>
This commit is contained in:
12
MOBILE.md
12
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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
119
packages/mobile/scripts/patch-ios-webview.ts
Normal file
119
packages/mobile/scripts/patch-ios-webview.ts
Normal file
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user