diff --git a/mobile/host-web-app/_layout.tsx b/mobile/host-web-app/_layout.tsx index eedd0da6315..c1c537aaec1 100644 --- a/mobile/host-web-app/_layout.tsx +++ b/mobile/host-web-app/_layout.tsx @@ -7,20 +7,22 @@ import { MobileWebNativeShellProvider, useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel' -import { installMobileWebHistorySessionFragment } from '../src/mobile-web/mobile-web-history-session-fragment' import { HostedPageTopInsetProvider } from '../src/mobile-web/hosted-page-top-inset' import { MobileWebNativeBehaviorAdapter, installMobileWebNativeBehaviorAdapters } from '../src/mobile-web/mobile-web-native-behavior-adapter' -import { installMobileWebQuerylessHistory } from '../src/mobile-web/mobile-web-queryless-history' +import { + installMobileWebHistoryUrlRewriter, + pinMobileWebShellSessionFragment, + stripMobileWebRouteQuery +} from '../src/mobile-web/mobile-web-history-url-rewriter' import { colors } from '../src/theme/mobile-theme' import { RpcClientProvider } from '../src/transport/client-context' import { MobileWebRouteErrorBoundary } from './mobile-web-route-error-boundary' import { MobileWebRouteRestorer } from './mobile-web-route-restorer' -installMobileWebHistorySessionFragment() -installMobileWebQuerylessHistory() +installMobileWebHistoryUrlRewriter([stripMobileWebRouteQuery, pinMobileWebShellSessionFragment]) installMobileWebNativeBehaviorAdapters() export default function HostMobileWebLayout() { diff --git a/mobile/src/mobile-web/mobile-web-history-session-fragment.test.ts b/mobile/src/mobile-web/mobile-web-history-session-fragment.test.ts deleted file mode 100644 index f165f2b0bac..00000000000 --- a/mobile/src/mobile-web/mobile-web-history-session-fragment.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { installMobileWebHistorySessionFragment } from './mobile-web-history-session-fragment' - -describe('mobile web history session fragment', () => { - it('keeps the opaque session on same-origin path and query writes', () => { - const target = historyTarget() - - expect(installMobileWebHistorySessionFragment(target)).toBe(true) - target.history.pushState({ page: 1 }, '', '/h/host/session/workspace?name=repo') - target.history.replaceState({ page: 2 }, '', '/h/host/tasks#other') - - expect(target.pushState).toHaveBeenCalledWith( - { page: 1 }, - '', - `https://orca-mobile-web.invalid/h/host/session/workspace?name=repo#${SESSION_ID}` - ) - expect(target.replaceState).toHaveBeenCalledWith( - { page: 2 }, - '', - `https://orca-mobile-web.invalid/h/host/tasks#${SESSION_ID}` - ) - }) - - it('leaves cross-origin and invalid URLs to browser enforcement', () => { - const target = historyTarget() - installMobileWebHistorySessionFragment(target) - - target.history.pushState(null, '', 'https://example.test/path') - target.history.replaceState(null, '', 'http://[') - - expect(target.pushState).toHaveBeenCalledWith(null, '', 'https://example.test/path') - expect(target.replaceState).toHaveBeenCalledWith(null, '', 'http://[') - }) - - it('does not wrap missing, malformed, or already wrapped session history', () => { - const target = historyTarget() - - expect( - installMobileWebHistorySessionFragment({ - ...target, - location: { ...target.location, hash: '#short' } - }) - ).toBe(false) - expect(installMobileWebHistorySessionFragment(target)).toBe(true) - expect(installMobileWebHistorySessionFragment(target)).toBe(false) - }) -}) - -const SESSION_ID = 'S'.repeat(43) - -function historyTarget() { - const pushState = vi.fn() - const replaceState = vi.fn() - return { - pushState, - replaceState, - history: { pushState, replaceState }, - location: { - hash: `#${SESSION_ID}`, - href: `https://orca-mobile-web.invalid/#${SESSION_ID}`, - origin: 'https://orca-mobile-web.invalid' - } - } -} diff --git a/mobile/src/mobile-web/mobile-web-history-session-fragment.ts b/mobile/src/mobile-web/mobile-web-history-session-fragment.ts deleted file mode 100644 index ee1678e73b9..00000000000 --- a/mobile/src/mobile-web/mobile-web-history-session-fragment.ts +++ /dev/null @@ -1,58 +0,0 @@ -type MobileWebHistoryWriter = (data: unknown, unused: string, url?: string | URL | null) => void - -type MobileWebHistoryTarget = { - history: { - pushState: MobileWebHistoryWriter - replaceState: MobileWebHistoryWriter - } - location: { - hash: string - href: string - origin: string - } -} - -const SHELL_SESSION_FRAGMENT_PATTERN = /^#[A-Za-z0-9_-]{43}$/ -const installedHistories = new WeakSet() - -export function installMobileWebHistorySessionFragment( - target: MobileWebHistoryTarget = window -): boolean { - const { history, location } = target - if (!SHELL_SESSION_FRAGMENT_PATTERN.test(location.hash) || installedHistories.has(history)) { - return false - } - history.pushState = sessionBoundHistoryWriter(history, history.pushState, location) - history.replaceState = sessionBoundHistoryWriter(history, history.replaceState, location) - installedHistories.add(history) - return true -} - -function sessionBoundHistoryWriter( - history: MobileWebHistoryTarget['history'], - writer: MobileWebHistoryWriter, - location: MobileWebHistoryTarget['location'] -): MobileWebHistoryWriter { - return (data, unused, url) => { - writer.call(history, data, unused, sessionBoundHistoryUrl(url, location)) - } -} - -function sessionBoundHistoryUrl( - value: string | URL | null | undefined, - location: MobileWebHistoryTarget['location'] -): string | URL | null | undefined { - if (value == null) { - return value - } - try { - const candidate = new URL(String(value), location.href) - if (candidate.origin !== location.origin) { - return value - } - candidate.hash = location.hash - return candidate.href - } catch { - return value - } -} diff --git a/mobile/src/mobile-web/mobile-web-history-url-rewriter.test.ts b/mobile/src/mobile-web/mobile-web-history-url-rewriter.test.ts new file mode 100644 index 00000000000..3df7fca21f5 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-history-url-rewriter.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import { + installMobileWebHistoryUrlRewriter, + pinMobileWebShellSessionFragment, + stripMobileWebRouteQuery +} from './mobile-web-history-url-rewriter' +import { mobileWebRouteQuery } from './mobile-web-route-query-cache' + +const SESSION_ID = 'S'.repeat(43) +const REWRITES = [stripMobileWebRouteQuery, pinMobileWebShellSessionFragment] + +describe('mobile web history url rewriter', () => { + it('strips the query and pins the opaque session in one write', () => { + const target = historyTarget() + + expect(installMobileWebHistoryUrlRewriter(REWRITES, target)).toBe(true) + target.history.pushState({ page: 1 }, '', '/h/host/session/workspace?name=repo') + target.history.replaceState({ page: 2 }, '', '/h/host/tasks#other') + + expect(target.pushState).toHaveBeenCalledWith( + { page: 1 }, + '', + `https://orca-mobile-web.invalid/h/host/session/workspace#${SESSION_ID}` + ) + expect(target.replaceState).toHaveBeenCalledWith( + { page: 2 }, + '', + `https://orca-mobile-web.invalid/h/host/tasks#${SESSION_ID}` + ) + expect(mobileWebRouteQuery('/h/host/session/workspace')).toEqual({ name: 'repo' }) + }) + + it('clears stale route state for a queryless write', () => { + const target = historyTarget() + installMobileWebHistoryUrlRewriter(REWRITES, target) + + target.history.replaceState(null, '', '/h/host/tasks?taskSource=linear') + target.history.replaceState(null, '', '/h/host/tasks') + + expect(mobileWebRouteQuery('/h/host/tasks')).toEqual({}) + }) + + it('leaves cross-origin and invalid URLs to browser enforcement', () => { + const target = historyTarget() + installMobileWebHistoryUrlRewriter(REWRITES, target) + + target.history.pushState(null, '', 'https://example.test/path?secret=value') + target.history.replaceState(null, '', 'http://[') + + expect(target.pushState).toHaveBeenCalledWith( + null, + '', + 'https://example.test/path?secret=value' + ) + expect(target.replaceState).toHaveBeenCalledWith(null, '', 'http://[') + }) + + it('leaves a fragment that is not a shell session alone', () => { + const target = historyTarget({ hash: '#short' }) + installMobileWebHistoryUrlRewriter(REWRITES, target) + + target.history.pushState(null, '', '/h/host/tasks#other') + + expect(target.pushState).toHaveBeenCalledWith( + null, + '', + 'https://orca-mobile-web.invalid/h/host/tasks#other' + ) + }) + + it('applies each rewrite in the order it was given', () => { + const target = historyTarget() + const order: string[] = [] + installMobileWebHistoryUrlRewriter( + [() => order.push('first'), () => order.push('second')], + target + ) + + target.history.pushState(null, '', '/h/host/tasks') + + expect(order).toEqual(['first', 'second']) + }) + + it('does not wrap an already wrapped history', () => { + const target = historyTarget() + + expect(installMobileWebHistoryUrlRewriter(REWRITES, target)).toBe(true) + expect(installMobileWebHistoryUrlRewriter(REWRITES, target)).toBe(false) + }) +}) + +function historyTarget(overrides: { hash?: string } = {}) { + const pushState = vi.fn() + const replaceState = vi.fn() + return { + pushState, + replaceState, + history: { pushState, replaceState }, + location: { + hash: overrides.hash ?? `#${SESSION_ID}`, + href: `https://orca-mobile-web.invalid/#${SESSION_ID}`, + origin: 'https://orca-mobile-web.invalid' + } + } +} diff --git a/mobile/src/mobile-web/mobile-web-history-url-rewriter.ts b/mobile/src/mobile-web/mobile-web-history-url-rewriter.ts new file mode 100644 index 00000000000..d98a28092b9 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-history-url-rewriter.ts @@ -0,0 +1,89 @@ +import { rememberMobileWebRouteQuery } from './mobile-web-route-query-cache' + +type MobileWebHistoryWriter = (data: unknown, unused: string, url?: string | URL | null) => void + +export type MobileWebHistoryLocation = { + hash: string + href: string + origin: string +} + +export type MobileWebHistoryTarget = { + history: { + pushState: MobileWebHistoryWriter + replaceState: MobileWebHistoryWriter + } + location: MobileWebHistoryLocation +} + +export type MobileWebHistoryUrlRewrite = ( + candidate: URL, + location: MobileWebHistoryLocation +) => void + +const SHELL_SESSION_FRAGMENT_PATTERN = /^#[A-Za-z0-9_-]{43}$/ + +const installedHistories = new WeakSet() + +/** The native shell hands the page its session in the fragment; every same-origin write keeps it. */ +export const pinMobileWebShellSessionFragment: MobileWebHistoryUrlRewrite = ( + candidate, + location +) => { + if (SHELL_SESSION_FRAGMENT_PATTERN.test(location.hash)) { + candidate.hash = location.hash + } +} + +/** Route params live in memory so the visible URL never carries them. */ +export const stripMobileWebRouteQuery: MobileWebHistoryUrlRewrite = (candidate) => { + rememberMobileWebRouteQuery(candidate.pathname, candidate.searchParams) + candidate.search = '' +} + +export function installMobileWebHistoryUrlRewriter( + rewrites: readonly MobileWebHistoryUrlRewrite[], + target: MobileWebHistoryTarget = window +): boolean { + const { history, location } = target + if (installedHistories.has(history)) { + return false + } + history.pushState = rewritingHistoryWriter(history, history.pushState, location, rewrites) + history.replaceState = rewritingHistoryWriter(history, history.replaceState, location, rewrites) + installedHistories.add(history) + return true +} + +function rewritingHistoryWriter( + history: MobileWebHistoryTarget['history'], + writer: MobileWebHistoryWriter, + location: MobileWebHistoryLocation, + rewrites: readonly MobileWebHistoryUrlRewrite[] +): MobileWebHistoryWriter { + return (data, unused, url) => { + writer.call(history, data, unused, rewrittenHistoryUrl(url, location, rewrites)) + } +} + +function rewrittenHistoryUrl( + value: string | URL | null | undefined, + location: MobileWebHistoryLocation, + rewrites: readonly MobileWebHistoryUrlRewrite[] +): string | URL | null | undefined { + if (value == null) { + return value + } + try { + const candidate = new URL(String(value), location.href) + if (candidate.origin !== location.origin) { + return value + } + for (const rewrite of rewrites) { + rewrite(candidate, location) + } + return candidate.href + } catch { + return value + } +} diff --git a/mobile/src/mobile-web/mobile-web-queryless-history.test.ts b/mobile/src/mobile-web/mobile-web-queryless-history.test.ts deleted file mode 100644 index cfab99509d3..00000000000 --- a/mobile/src/mobile-web/mobile-web-queryless-history.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { mobileWebRouteQuery } from './mobile-web-route-query-cache' -import { installMobileWebQuerylessHistory } from './mobile-web-queryless-history' - -describe('mobile web queryless history', () => { - it('keeps same-origin page state in memory and out of the URL', () => { - const target = historyTarget() - - expect(installMobileWebQuerylessHistory(target)).toBe(true) - target.history.pushState({ route: 'session' }, '', '/h/host/session/workspace?name=repo') - - expect(target.pushState).toHaveBeenCalledWith( - { route: 'session' }, - '', - 'https://orca-mobile-web.invalid/h/host/session/workspace' - ) - expect(mobileWebRouteQuery('/h/host/session/workspace')).toEqual({ name: 'repo' }) - }) - - it('clears stale state for a queryless write and leaves foreign URLs to the browser', () => { - const target = historyTarget() - installMobileWebQuerylessHistory(target) - target.history.replaceState(null, '', '/h/host/tasks?taskSource=linear') - target.history.replaceState(null, '', '/h/host/tasks') - target.history.pushState(null, '', 'https://example.test/path?secret=value') - - expect(mobileWebRouteQuery('/h/host/tasks')).toEqual({}) - expect(target.pushState).toHaveBeenLastCalledWith( - null, - '', - 'https://example.test/path?secret=value' - ) - }) - - it('does not wrap a history twice', () => { - const target = historyTarget() - expect(installMobileWebQuerylessHistory(target)).toBe(true) - expect(installMobileWebQuerylessHistory(target)).toBe(false) - }) -}) - -function historyTarget() { - const pushState = vi.fn() - const replaceState = vi.fn() - return { - pushState, - replaceState, - history: { pushState, replaceState }, - location: { - href: 'https://orca-mobile-web.invalid/#shell', - origin: 'https://orca-mobile-web.invalid' - } - } -} diff --git a/mobile/src/mobile-web/mobile-web-queryless-history.ts b/mobile/src/mobile-web/mobile-web-queryless-history.ts deleted file mode 100644 index 659bf2a8a63..00000000000 --- a/mobile/src/mobile-web/mobile-web-queryless-history.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { rememberMobileWebRouteQuery } from './mobile-web-route-query-cache' - -type MobileWebHistoryWriter = (data: unknown, unused: string, url?: string | URL | null) => void - -type MobileWebHistoryTarget = { - history: { - pushState: MobileWebHistoryWriter - replaceState: MobileWebHistoryWriter - } - location: { - href: string - origin: string - } -} - -const installedHistories = new WeakSet() - -export function installMobileWebQuerylessHistory(target: MobileWebHistoryTarget = window): boolean { - const { history, location } = target - if (installedHistories.has(history)) { - return false - } - history.pushState = querylessHistoryWriter(history, history.pushState, location) - history.replaceState = querylessHistoryWriter(history, history.replaceState, location) - installedHistories.add(history) - return true -} - -function querylessHistoryWriter( - history: MobileWebHistoryTarget['history'], - writer: MobileWebHistoryWriter, - location: MobileWebHistoryTarget['location'] -): MobileWebHistoryWriter { - return (data, unused, url) => { - writer.call(history, data, unused, querylessHistoryUrl(url, location)) - } -} - -function querylessHistoryUrl( - value: string | URL | null | undefined, - location: MobileWebHistoryTarget['location'] -): string | URL | null | undefined { - if (value == null) { - return value - } - try { - const candidate = new URL(String(value), location.href) - if (candidate.origin !== location.origin) { - return value - } - rememberMobileWebRouteQuery(candidate.pathname, candidate.searchParams) - candidate.search = '' - return candidate.href - } catch { - return value - } -} diff --git a/mobile/src/session/mobile-web-session-screen-binding-source.test.ts b/mobile/src/session/mobile-web-session-screen-binding-source.test.ts index e0beb768573..81982dcf99e 100644 --- a/mobile/src/session/mobile-web-session-screen-binding-source.test.ts +++ b/mobile/src/session/mobile-web-session-screen-binding-source.test.ts @@ -70,10 +70,10 @@ describe('mobile web session screen binding', () => { }) it('keeps one native-shell channel alive across hosted route navigation', () => { - expect(hostedLayout).toContain('installMobileWebHistorySessionFragment()') + expect(hostedLayout).toContain('installMobileWebHistoryUrlRewriter(') expect(hostedLayout).toContain('') expect(hostedLayout).toContain('') - expect(hostedLayout.indexOf('installMobileWebHistorySessionFragment()')).toBeLessThan( + expect(hostedLayout.indexOf('installMobileWebHistoryUrlRewriter(')).toBeLessThan( hostedLayout.indexOf('export default function HostMobileWebLayout()') ) expect(hostedLayout.indexOf('')).toBeLessThan(