From c01caf0a7a5d9db6aed587fa614f415a15592a86 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 19 Oct 2026 00:47:57 +0900 Subject: [PATCH] fix(pages): allow Capacitor iOS navigation --- packages/void/src/pages/client.ts | 3 +- packages/void/src/pages/navigation-engine.ts | 14 +++-- packages/void/src/pages/url.ts | 8 +++ packages/void/test/unit/pages-client.test.ts | 66 ++++++++++++++++++-- 4 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 packages/void/src/pages/url.ts diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts index 9bf271cb4..3f054abff 100644 --- a/packages/void/src/pages/client.ts +++ b/packages/void/src/pages/client.ts @@ -17,6 +17,7 @@ import { type PrefetchOptions, } from './navigation-engine.ts'; import { ISLAND_FRONTMATTER_ID } from './serialize.ts'; +import { isSameOrigin } from './url.ts'; export { getValidationErrors, isModifiedEvent, mergeDataIntoHref } from './adapter-helpers.ts'; export { @@ -98,7 +99,7 @@ function normalizeUrl(value: string | URL): string { return value; } const url = toUrl(value); - return url.origin === getUrlOrigin() ? url.pathname + url.search + url.hash : url.href; + return isSameOrigin(url, getUrlOrigin()) ? url.pathname + url.search + url.hash : url.href; } function navigationMethod(method: string): NavigationMethod { diff --git a/packages/void/src/pages/navigation-engine.ts b/packages/void/src/pages/navigation-engine.ts index 11700fc11..71cb582f3 100644 --- a/packages/void/src/pages/navigation-engine.ts +++ b/packages/void/src/pages/navigation-engine.ts @@ -7,6 +7,7 @@ import { PrefetchCache, parseCacheFor } from './prefetch.ts'; import type { CacheForInput, CacheForResult } from './prefetch.ts'; import type { PageObject } from './protocol.ts'; import { createScrollRestoration } from './scroll-restoration.ts'; +import { isSameOrigin } from './url.ts'; export type PageComponentModule = { default: unknown; @@ -595,13 +596,18 @@ export async function createPageNavigationEngine( deferredRegistry: DeferredRegistry, ): Promise { // Page data only comes from this origin. Other http(s) URLs get a full page - // load; other schemes (javascript:, data:, ...) must never reach location.href. + // load. Capacitor's iOS WebView uses capacitor: for local pages. + // Unsafe schemes (javascript:, data:, ...) must never reach location.href. // Check the scheme first: a blob: URL reports the origin that created it. const target = new URL(url, window.location.origin); - if (target.protocol !== 'http:' && target.protocol !== 'https:') { + if ( + target.protocol !== 'http:' && + target.protocol !== 'https:' && + target.protocol !== 'capacitor:' + ) { throw new Error(`Void Router: refusing to navigate to a ${target.protocol} URL.`); } - if (target.origin !== window.location.origin) { + if (!isSameOrigin(target, window.location.origin)) { if (method !== 'GET') { throw new Error('Void Router: non-GET visits must target the current origin.'); } @@ -676,7 +682,7 @@ export async function createPageNavigationEngine( if (response?.redirected) { const redirectUrl = new URL(response.url, window.location.origin); - if (redirectUrl.origin !== window.location.origin) { + if (!isSameOrigin(redirectUrl, window.location.origin)) { window.location.href = method === 'GET' ? url : response.url; return undefined; } diff --git a/packages/void/src/pages/url.ts b/packages/void/src/pages/url.ts new file mode 100644 index 000000000..e6e62cc57 --- /dev/null +++ b/packages/void/src/pages/url.ts @@ -0,0 +1,8 @@ +export function isSameOrigin(url: URL, origin: string): boolean { + // Custom schemes have an opaque URL.origin, even for different hosts. + if (url.protocol === 'capacitor:') { + const current = new URL(origin); + return current.protocol === 'capacitor:' && url.host !== '' && url.host === current.host; + } + return url.origin === origin; +} diff --git a/packages/void/test/unit/pages-client.test.ts b/packages/void/test/unit/pages-client.test.ts index 72fa4d85a..15dd25be2 100644 --- a/packages/void/test/unit/pages-client.test.ts +++ b/packages/void/test/unit/pages-client.test.ts @@ -11,7 +11,7 @@ type TestRouterConfig = Partial> & { adapter?: Partial; }; -function stubClientGlobals() { +function stubClientGlobals(origin = 'http://localhost') { const documentStub = { createElement: vi.fn(() => ({})), getElementById: vi.fn((id: string) => { @@ -42,7 +42,7 @@ function stubClientGlobals() { vi.stubGlobal('window', { addEventListener: vi.fn(), location: { - origin: 'http://localhost', + origin, pathname: '/', search: '', hash: '', @@ -924,8 +924,8 @@ describe('createVoidRouter', () => { }); describe('createVoidRouter other-origin visits', () => { - async function createRouter() { - stubClientGlobals(); + async function createRouter(origin = 'http://localhost') { + stubClientGlobals(origin); const fetchMock = vi.fn(async () => pageJson({ component: 'users', props: { users: [] } })); vi.stubGlobal('fetch', fetchMock); const applyUpdate = vi.fn(); @@ -937,6 +937,64 @@ describe('createVoidRouter other-origin visits', () => { return { router, fetchMock, applyUpdate }; } + it.each([ + ['GET', '/users'], + ['GET', 'capacitor://localhost/users'], + ['POST', '/users'], + ['POST', 'capacitor://localhost/users'], + ])('loads Capacitor %s visits to %s in place', async (method, url) => { + const { router, fetchMock, applyUpdate } = await createRouter('capacitor://localhost'); + await router.visit(url, { method }); + expect(fetchMock).toHaveBeenCalledWith('/users', expect.objectContaining({ method })); + expect(applyUpdate).toHaveBeenCalledTimes(1); + expect(window.location.href).toBeUndefined(); + }); + + it('follows a same-host Capacitor action redirect in place', async () => { + const { router, fetchMock, applyUpdate } = await createRouter('capacitor://localhost'); + const response = pageJson({}); + Object.defineProperties(response, { + redirected: { value: true }, + url: { value: 'capacitor://localhost/users' }, + }); + fetchMock.mockResolvedValueOnce(response); + await router.visit('/submit', { method: 'POST' }); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/users', + expect.objectContaining({ method: 'GET' }), + ); + expect(applyUpdate).toHaveBeenCalledTimes(1); + expect(window.location.href).toBeUndefined(); + }); + + it('does not fetch another Capacitor host for GET navigation', async () => { + const { router, fetchMock, applyUpdate } = await createRouter('capacitor://localhost'); + await router.visit('capacitor://other/users'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(applyUpdate).not.toHaveBeenCalled(); + expect(window.location.href).toBe('capacitor://other/users'); + }); + + it('does not treat different Capacitor hosts as the same origin', async () => { + const { router, fetchMock } = await createRouter('capacitor://localhost'); + await expect(router.visit('capacitor://other/users', { method: 'POST' })).rejects.toThrow( + 'Void Router: non-GET visits must target the current origin.', + ); + expect(fetchMock).not.toHaveBeenCalled(); + expect(window.location.href).toBeUndefined(); + }); + + it.each(['javascript:alert(1)', 'data:text/html,x', 'file:///users', 'app://localhost/users'])( + 'still rejects %s from Capacitor', + async (url) => { + const { router, fetchMock } = await createRouter('capacitor://localhost'); + await expect(router.visit(url)).rejects.toThrow(/refusing to navigate/); + expect(fetchMock).not.toHaveBeenCalled(); + expect(window.location.href).toBeUndefined(); + }, + ); + it.each(['javascript:alert(1)', ' JaVaScRiPt:alert(1)', 'data:text/html,x'])( 'rejects %j without fetching or assigning location', async (url) => {