From ae0d4e4520693665f0c774dc3834709ab0e5420f Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 24 Sep 2026 12:42:34 +0200 Subject: [PATCH] feat(native): the UI knows when it runs inside the app Everything the server's UI does differently inside the iOS and Android app lives under src/native and is inert in a browser. Sign-in: SSO and the passkey button go through the system browser (signInThroughBrowser), because identity providers refuse embedded browsers and WebAuthn needs a domain association no build can have for every server. The browser end is the new /native-handoff page: it signs the user in if needed and hands the session over only after an explicit tap, so a link carrying somebody else's challenge cannot pass a session on silently. The verifier is hashed with @noble/hashes because crypto.subtle is missing on the plain-http servers many home networks use. An OIDC-only install no longer bounces to the IdP inside the WebView, and passkey management is hidden in the app. Downloads: the WebView cannot save a blob, so downloadBlob, downloadFile and openFile write to the app cache and open the share sheet. The eight places that built their own download anchor now use downloadBlob, which they should have anyway. The rest: "Change server" on the login page and in the account settings, the splash hides after the first frame, Android's back button walks history before it leaves the app, the status bar follows the scheme, and on iOS the start page is refreshed and every chunk of the build (listed in the new asset-manifest.json) is pulled into WebKit's cache so the app can start without network. --- client/package.json | 1 + client/src/App.tsx | 2 + client/src/api/nativeAuth.ts | 12 ++ .../src/components/Budget/useBudgetPanel.ts | 8 +- .../Collections/collectionFile.test.ts | 2 +- .../components/Collections/collectionFile.ts | 12 +- .../components/Planner/TripExportModal.tsx | 17 +-- .../components/Settings/AccountTab.test.tsx | 2 +- client/src/components/Settings/AccountTab.tsx | 20 ++- .../Settings/NativeServerSection.test.tsx | 33 +++++ .../Settings/NativeServerSection.tsx | 25 ++++ client/src/components/Studio/spreadFile.ts | 12 +- client/src/main.tsx | 3 + .../src/mobile/screens/settings/MSettings.tsx | 2 + .../screens/settings/MSettingsAccount.tsx | 15 +- .../screens/settings/MSettingsServer.tsx | 18 +++ .../screens/trip/sheets/MExportSheet.tsx | 21 +-- client/src/native/browserSignIn.test.ts | 63 ++++++++ client/src/native/browserSignIn.ts | 61 ++++++++ client/src/native/nativeApp.test.ts | 96 ++++++++++++ client/src/native/nativeApp.ts | 49 +++++++ client/src/native/nativeFiles.test.ts | 49 +++++++ client/src/native/nativeFiles.ts | 46 ++++++ client/src/native/offlineShell.test.ts | 66 +++++++++ client/src/native/offlineShell.ts | 51 +++++++ client/src/native/platform.ts | 15 ++ client/src/pages/LoginPage.tsx | 28 +++- client/src/pages/NativeHandoffPage.test.tsx | 53 +++++++ client/src/pages/NativeHandoffPage.tsx | 38 +++++ .../src/pages/login/useLogin.native.test.tsx | 137 ++++++++++++++++++ client/src/pages/login/useLogin.ts | 37 ++++- .../nativeHandoff/useNativeHandoff.test.tsx | 119 +++++++++++++++ .../pages/nativeHandoff/useNativeHandoff.ts | 66 +++++++++ client/src/utils/fileDownload.ts | 10 ++ .../unit/utils/fileDownload.native.test.ts | 46 ++++++ client/vite.config.js | 4 + package-lock.json | 2 +- 37 files changed, 1153 insertions(+), 88 deletions(-) create mode 100644 client/src/api/nativeAuth.ts create mode 100644 client/src/components/Settings/NativeServerSection.test.tsx create mode 100644 client/src/components/Settings/NativeServerSection.tsx create mode 100644 client/src/mobile/screens/settings/MSettingsServer.tsx create mode 100644 client/src/native/browserSignIn.test.ts create mode 100644 client/src/native/browserSignIn.ts create mode 100644 client/src/native/nativeApp.test.ts create mode 100644 client/src/native/nativeApp.ts create mode 100644 client/src/native/nativeFiles.test.ts create mode 100644 client/src/native/nativeFiles.ts create mode 100644 client/src/native/offlineShell.test.ts create mode 100644 client/src/native/offlineShell.ts create mode 100644 client/src/native/platform.ts create mode 100644 client/src/pages/NativeHandoffPage.test.tsx create mode 100644 client/src/pages/NativeHandoffPage.tsx create mode 100644 client/src/pages/login/useLogin.native.test.tsx create mode 100644 client/src/pages/nativeHandoff/useNativeHandoff.test.tsx create mode 100644 client/src/pages/nativeHandoff/useNativeHandoff.ts create mode 100644 client/tests/unit/utils/fileDownload.native.test.ts diff --git a/client/package.json b/client/package.json index 1bb971287..4d116aa1c 100644 --- a/client/package.json +++ b/client/package.json @@ -53,6 +53,7 @@ "@fontsource/poppins": "^5.2.7", "@mapbox/mapbox-gl-rtl-text": "^0.4.0", "@maplibre/maplibre-gl-leaflet": "0.1.4", + "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@trek/shared": "*", "axios": "^1.6.7", diff --git a/client/src/App.tsx b/client/src/App.tsx index 0df993c00..5dfff295c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -40,6 +40,7 @@ import { managedRoutes } from './managed' // for a reload. const PluginPage = lazyWithRetry(() => import('./pages/PluginPage')) const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage')) +const NativeHandoffPage = lazyWithRetry(() => import('./pages/NativeHandoffPage')) const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage')) const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage')) const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage')) @@ -397,6 +398,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} } /> diff --git a/client/src/api/nativeAuth.ts b/client/src/api/nativeAuth.ts new file mode 100644 index 000000000..c17705a7d --- /dev/null +++ b/client/src/api/nativeAuth.ts @@ -0,0 +1,12 @@ +import type { NativeExchangeRequest, NativeHandoffResponse } from '@trek/shared' +import { apiClient } from './client' + +/** The two halves of the sign-in hand-off between system browser and app. */ +export const nativeAuthApi = { + /** Browser side: trade a signed-in session for a one-time code bound to `challenge`. */ + handoff: (challenge: string): Promise => + apiClient.post('/auth/native/handoff', { challenge }).then((r) => r.data), + /** App side: redeem the code with the verifier; the response sets the session cookie. */ + exchange: (body: NativeExchangeRequest): Promise => + apiClient.post('/auth/native/exchange', body).then(() => undefined), +} diff --git a/client/src/components/Budget/useBudgetPanel.ts b/client/src/components/Budget/useBudgetPanel.ts index e8105d573..692edff22 100644 --- a/client/src/components/Budget/useBudgetPanel.ts +++ b/client/src/components/Budget/useBudgetPanel.ts @@ -10,6 +10,7 @@ import { currencyDecimals } from '../../utils/formatters' import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD, hasCustomMemberSplit } from './BudgetPanel.helpers' import { PIE_COLORS } from './BudgetPanel.constants' import type { TripMember } from './BudgetPanelMemberChips' +import { downloadBlob } from '../../utils/fileDownload' function useIsDark(): boolean { const [dark, setDark] = useState(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')) @@ -183,13 +184,8 @@ export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) { const bom = '' const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim() - a.download = `budget-${safeName}.csv` - a.click() - URL.revokeObjectURL(url) + downloadBlob(blob, `budget-${safeName}.csv`) } const th: CSSProperties = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' } diff --git a/client/src/components/Collections/collectionFile.test.ts b/client/src/components/Collections/collectionFile.test.ts index 20a4f9a81..657f7232b 100644 --- a/client/src/components/Collections/collectionFile.test.ts +++ b/client/src/components/Collections/collectionFile.test.ts @@ -75,10 +75,10 @@ describe('downloads', () => { expect(click).toHaveBeenCalledTimes(1) expect(names).toEqual(['lisbon' + COLLECTION_FILE_EXTENSION]) expect(created[0].type).toBe('application/json') - expect(document.querySelector('a')).toBeNull() expect(revoke).not.toHaveBeenCalled() vi.runAllTimers() expect(revoke).toHaveBeenCalledWith('blob:x') + expect(document.querySelector('a')).toBeNull() }) it('FE-COLL-FILE-014: hands the browser a GPX under the list name', async () => { diff --git a/client/src/components/Collections/collectionFile.ts b/client/src/components/Collections/collectionFile.ts index 94dc6714a..b0b6487c9 100644 --- a/client/src/components/Collections/collectionFile.ts +++ b/client/src/components/Collections/collectionFile.ts @@ -6,6 +6,7 @@ import { type CollectionGpxProblem, type CollectionGpxReadResult, } from '@trek/shared' +import { downloadBlob } from '../../utils/fileDownload' /** * The browser half of list export and import (#2198, #2301). @@ -37,16 +38,7 @@ export function collectionFileName(name: string, extension = COLLECTION_FILE_EXT } function download(content: string, type: string, fileName: string): void { - const url = URL.createObjectURL(new Blob([content], { type })) - const a = document.createElement('a') - a.href = url - a.download = fileName - document.body.appendChild(a) - a.click() - a.remove() - // Revoked on the next tick: revoking synchronously can cancel the download - // in some browsers before it has started reading the blob. - setTimeout(() => URL.revokeObjectURL(url), 0) + downloadBlob(new Blob([content], { type }), fileName) } /** Hand the file to the browser as a download. */ diff --git a/client/src/components/Planner/TripExportModal.tsx b/client/src/components/Planner/TripExportModal.tsx index ba91783d7..502ed1cb3 100644 --- a/client/src/components/Planner/TripExportModal.tsx +++ b/client/src/components/Planner/TripExportModal.tsx @@ -7,6 +7,7 @@ import { useToast } from '../shared/Toast' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' import { useSettingsStore } from '../../store/settingsStore' import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' +import { downloadBlob } from '../../utils/fileDownload' /** * What a GPX download can carry. Worded by what someone wants on their device @@ -67,18 +68,6 @@ export function TripExportModal({ const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, tripId) const fileBase = trip?.title || 'trip' - // Shared tail of every download: Firefox and Safari cancel the download when - // the object URL is revoked before they picked the blob up, hence the delay. - const saveBlob = (blob: Blob, filename: string) => { - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100) - } - const exportPdf = async () => { if (busy) return setBusy('pdf') @@ -107,7 +96,7 @@ export function TripExportModal({ try { const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' }) if (!res.ok) throw new Error() - saveBlob(await res.blob(), `${fileBase}.ics`) + downloadBlob(await res.blob(), `${fileBase}.ics`) onClose() } catch { toast.error(t('planner.icsExportFailed')) @@ -125,7 +114,7 @@ export function TripExportModal({ // "nothing happened" and "the download broke" look identical otherwise. if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return } if (!res.ok) throw new Error() - saveBlob(await res.blob(), `${fileBase}.gpx`) + downloadBlob(await res.blob(), `${fileBase}.gpx`) onClose() } catch { toast.error(t('dayplan.gpxFailed')) diff --git a/client/src/components/Settings/AccountTab.test.tsx b/client/src/components/Settings/AccountTab.test.tsx index 11bd84931..af7af5102 100644 --- a/client/src/components/Settings/AccountTab.test.tsx +++ b/client/src/components/Settings/AccountTab.test.tsx @@ -598,7 +598,7 @@ describe('AccountTab – Backup codes', () => { expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); expect(downloadName).toBe('trek-mfa-backup-codes.txt'); - expect(revokeObjectURL).toHaveBeenCalledWith('blob:codes'); + await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:codes')); expect(document.querySelector('a[download]')).toBeNull(); createObjectURL.mockRestore(); diff --git a/client/src/components/Settings/AccountTab.tsx b/client/src/components/Settings/AccountTab.tsx index 126d296d6..88ec2e951 100644 --- a/client/src/components/Settings/AccountTab.tsx +++ b/client/src/components/Settings/AccountTab.tsx @@ -10,6 +10,9 @@ import { getApiErrorMessage } from '../../types' import type { UserWithOidc } from '../../types' import Section from './Section' import PasskeysSection from './PasskeysSection' +import NativeServerSection from './NativeServerSection' +import { isNativeApp } from '../../native/platform' +import { downloadBlob } from '../../utils/fileDownload' const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' @@ -100,15 +103,7 @@ export default function AccountTab(): React.ReactElement { const downloadBackupCodes = () => { if (!backupCodesText) return - const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'trek-mfa-backup-codes.txt' - document.body.appendChild(a) - a.click() - a.remove() - URL.revokeObjectURL(url) + downloadBlob(new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' }), 'trek-mfa-backup-codes.txt') } const printBackupCodes = () => { @@ -160,6 +155,7 @@ export default function AccountTab(): React.ReactElement { return ( <> +
@@ -405,8 +401,10 @@ export default function AccountTab(): React.ReactElement {
- {/* Passkeys */} - + {/* Passkeys. Not in the app: WebAuthn in its WebView needs a domain + association no build can have for every server, so they are managed + in the browser (signing in with one works through it too). */} + {!isNativeApp() && } {/* Avatar */}
diff --git a/client/src/components/Settings/NativeServerSection.test.tsx b/client/src/components/Settings/NativeServerSection.test.tsx new file mode 100644 index 000000000..c8c51a994 --- /dev/null +++ b/client/src/components/Settings/NativeServerSection.test.tsx @@ -0,0 +1,33 @@ +// FE-SETTINGS-NATIVE-SERVER-001 to FE-SETTINGS-NATIVE-SERVER-004 +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '../../../tests/helpers/render' +import NativeServerSection from './NativeServerSection' +import MSettingsServer from '../../mobile/screens/settings/MSettingsServer' +import { nativeServerHost } from '../../native/platform' +import { TrekShell } from '../../native/trekShell' + +vi.mock('../../native/platform', () => ({ nativeServerHost: vi.fn() })) +vi.mock('../../native/trekShell', () => ({ TrekShell: { resetServer: vi.fn() } })) + +beforeEach(() => { + vi.mocked(TrekShell.resetServer).mockReset().mockResolvedValue() +}) + +describe.each([ + ['desktop', NativeServerSection], + ['mobile', MSettingsServer], +])('the %s server section', (_shell, Section) => { + it('renders nothing in a browser', () => { + vi.mocked(nativeServerHost).mockReturnValue(null) + const { container } = render(
) + expect(container).toBeEmptyDOMElement() + }) + + it('names the server and leads back to the address screen', () => { + vi.mocked(nativeServerHost).mockReturnValue('trek.example.com') + render(
) + expect(screen.getByText('This app is connected to trek.example.com.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Change server' })) + expect(TrekShell.resetServer).toHaveBeenCalled() + }) +}) diff --git a/client/src/components/Settings/NativeServerSection.tsx b/client/src/components/Settings/NativeServerSection.tsx new file mode 100644 index 000000000..845d06da8 --- /dev/null +++ b/client/src/components/Settings/NativeServerSection.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { Server } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { nativeServerHost } from '../../native/platform' +import { TrekShell } from '../../native/trekShell' +import Section from './Section' + +/** Only inside the iOS/Android app: which server it talks to, and the way to another one. */ +export default function NativeServerSection(): React.ReactElement | null { + const { t } = useTranslation() + const host = nativeServerHost() + if (!host) return null + return ( +
+

{t('native.settings.serverHint', { server: host })}

+ +
+ ) +} diff --git a/client/src/components/Studio/spreadFile.ts b/client/src/components/Studio/spreadFile.ts index 3ee22d9f9..8d09f6852 100644 --- a/client/src/components/Studio/spreadFile.ts +++ b/client/src/components/Studio/spreadFile.ts @@ -1,5 +1,6 @@ import type { BookPageSetup, BookSpread } from '@trek/shared' import { bookElementSchema } from '@trek/shared' +import { downloadBlob } from '../../utils/fileDownload' /** * A spread as a file, so a design can leave the book it was made in. @@ -202,16 +203,7 @@ export function importSpread(raw: unknown, page: BookPageSetup): BookSpread | nu /** Hand the file to the browser as a download. */ export function downloadSpread(spread: BookSpread, page: BookPageSetup, name: string) { const json = JSON.stringify(exportSpread(spread, page, name), null, 2) - const url = URL.createObjectURL(new Blob([json], { type: 'application/json' })) - const a = document.createElement('a') - a.href = url - a.download = `${slug(name) || 'spread'}.trekspread.json` - document.body.appendChild(a) - a.click() - a.remove() - // Revoked on the next tick: revoking synchronously can cancel the download - // in some browsers before it has started reading the blob. - setTimeout(() => URL.revokeObjectURL(url), 0) + downloadBlob(new Blob([json], { type: 'application/json' }), `${slug(name) || 'spread'}.trekspread.json`) } function slug(name: string): string { diff --git a/client/src/main.tsx b/client/src/main.tsx index 7d06410e9..4309aa4ee 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -36,6 +36,7 @@ import { startConnectivityProbe } from './sync/connectivity' import { requestPersistentStorage } from './sync/persistentStorage' import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary' import { installGlobalErrorHandlers } from './utils/globalErrorHandlers' +import { startNativeApp } from './native/nativeApp' maybeInstallTouchDragPolyfill() startConnectivityProbe() @@ -43,6 +44,8 @@ startConnectivityProbe() requestPersistentStorage() // Event handlers and async code never reach a boundary; this is where they land. installGlobalErrorHandlers() +// Inside the iOS/Android app: splash, back button, status bar, offline start (iOS). +startNativeApp() ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/client/src/mobile/screens/settings/MSettings.tsx b/client/src/mobile/screens/settings/MSettings.tsx index d6f5f438d..26da3f553 100644 --- a/client/src/mobile/screens/settings/MSettings.tsx +++ b/client/src/mobile/screens/settings/MSettings.tsx @@ -28,6 +28,7 @@ import MSettingsNotifications from './MSettingsNotifications' import MSettingsIntegrations from './MSettingsIntegrations' import MSettingsAccount from './MSettingsAccount' import MSettingsAbout from './MSettingsAbout' +import MSettingsServer from './MSettingsServer' interface SectionTab { id: string @@ -122,6 +123,7 @@ export default function MSettings() { {/* Per-plugin settings still reuse the existing responsive tab. */} {active.id === 'plugins' && hasPlugins && } {active.id === 'offline' && } + {active.id === 'account' && } {active.id === 'account' && } {active.id === 'about' && appVersion && } diff --git a/client/src/mobile/screens/settings/MSettingsAccount.tsx b/client/src/mobile/screens/settings/MSettingsAccount.tsx index caa559e66..1fd6c6845 100644 --- a/client/src/mobile/screens/settings/MSettingsAccount.tsx +++ b/client/src/mobile/screens/settings/MSettingsAccount.tsx @@ -27,6 +27,8 @@ import { getApiErrorMessage } from '../../../types' import type { UserWithOidc } from '../../../types' import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi' import MConfirmSheet from './MConfirmSheet' +import { downloadBlob } from '../../../utils/fileDownload' +import { isNativeApp } from '../../../native/platform' const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' @@ -130,15 +132,7 @@ export default function MSettingsAccount() { } const downloadBackupCodes = () => { - const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'trek-mfa-backup-codes.txt' - document.body.appendChild(a) - a.click() - a.remove() - URL.revokeObjectURL(url) + downloadBlob(new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' }), 'trek-mfa-backup-codes.txt') } const printBackupCodes = () => { @@ -502,7 +496,8 @@ export default function MSettingsAccount() { )} - + {/* Not in the app: see the passkeys note in components/Settings/AccountTab.tsx. */} + {!isNativeApp() && } + {t('native.settings.serverHint', { server: host })} + { void TrekShell.resetServer() }}>{t('native.changeServer')} + + ) +} diff --git a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx index cea7d4409..9235e8409 100644 --- a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx @@ -9,6 +9,7 @@ import { useTranslation } from '../../../../i18n' import { INNER_CLS, TileHeader } from './MTripSheetUi' import type { MTripSheetsProps } from '../MTripShell' import type { LucideIcon } from 'lucide-react' +import { downloadBlob } from '../../../../utils/fileDownload' /** * Export sheet ('export', opened from the Mehr sheet): the desktop day-plan @@ -71,16 +72,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) { try { const res = await fetch(`/api/trips/${planner.tripId}/export.ics`, { credentials: 'include' }) if (!res.ok) throw new Error() - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${planner.trip?.title || 'trip'}.ics` - document.body.appendChild(a) - a.click() - // Firefox/Safari cancel the download when the object URL is revoked - // before they picked the blob up. - setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100) + downloadBlob(await res.blob(), `${planner.trip?.title || 'trip'}.ics`) shell.closeSheet() } catch { planner.toast.error(t('planner.icsExportFailed')) @@ -99,14 +91,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) { const res = await fetch(`/api/trips/${planner.tripId}/places/export.gpx`, { credentials: 'include' }) if (res.status === 404) { planner.toast.info(t('dayplan.gpxEmpty')); return } if (!res.ok) throw new Error() - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${planner.trip?.title || 'trip'}.gpx` - document.body.appendChild(a) - a.click() - setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100) + downloadBlob(await res.blob(), `${planner.trip?.title || 'trip'}.gpx`) shell.closeSheet() } catch { planner.toast.error(t('dayplan.gpxFailed')) diff --git a/client/src/native/browserSignIn.test.ts b/client/src/native/browserSignIn.test.ts new file mode 100644 index 000000000..624b04c97 --- /dev/null +++ b/client/src/native/browserSignIn.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { sha256 } from '@noble/hashes/sha2' +import { bytesToHex } from '@noble/hashes/utils' +import { BrowserSignInCancelled, createHandoffSecret, handoffUrl, signInThroughBrowser } from './browserSignIn' +import { TrekShell } from './trekShell' +import { nativeAuthApi } from '../api/nativeAuth' + +vi.mock('./trekShell', () => ({ APP_URL_SCHEME: 'com.liketrek.trek', TrekShell: { authenticate: vi.fn() } })) +vi.mock('../api/nativeAuth', () => ({ nativeAuthApi: { exchange: vi.fn() } })) + +beforeEach(() => { + vi.mocked(TrekShell.authenticate).mockReset() + vi.mocked(nativeAuthApi.exchange).mockReset().mockResolvedValue() +}) + +describe('createHandoffSecret', () => { + it('pairs a fresh 64-character verifier with the hex sha256 the server checks against', () => { + const { verifier, challenge } = createHandoffSecret() + expect(verifier).toMatch(/^[0-9a-f]{64}$/) + expect(challenge).toBe(bytesToHex(sha256(new TextEncoder().encode(verifier)))) + expect(createHandoffSecret().verifier).not.toBe(verifier) + }) +}) + +describe('handoffUrl', () => { + it('points at the hand-off page with the challenge and an optional start', () => { + expect(handoffUrl('https://trek.example.com', 'c')).toBe('https://trek.example.com/native-handoff?challenge=c') + expect(handoffUrl('https://trek.example.com', 'c', 'oidc')).toBe('https://trek.example.com/native-handoff?challenge=c&start=oidc') + }) +}) + +describe('signInThroughBrowser', () => { + it('opens the hand-off page and redeems the returned code with the verifier', async () => { + vi.mocked(TrekShell.authenticate).mockResolvedValue({ url: 'com.liketrek.trek://auth?code=abc' }) + + await signInThroughBrowser('oidc') + + const opened = new URL(vi.mocked(TrekShell.authenticate).mock.calls[0]![0].url) + expect(opened.pathname).toBe('/native-handoff') + expect(opened.searchParams.get('start')).toBe('oidc') + expect(vi.mocked(TrekShell.authenticate).mock.calls[0]![0].callbackScheme).toBe('com.liketrek.trek') + const { code, verifier } = vi.mocked(nativeAuthApi.exchange).mock.calls[0]![0] + expect(code).toBe('abc') + expect(bytesToHex(sha256(new TextEncoder().encode(verifier)))).toBe(opened.searchParams.get('challenge')) + }) + + it('turns a closed browser into BrowserSignInCancelled', async () => { + vi.mocked(TrekShell.authenticate).mockRejectedValue(Object.assign(new Error('closed'), { code: 'CANCELLED' })) + await expect(signInThroughBrowser()).rejects.toBeInstanceOf(BrowserSignInCancelled) + expect(nativeAuthApi.exchange).not.toHaveBeenCalled() + }) + + it('passes other failures on', async () => { + vi.mocked(TrekShell.authenticate).mockRejectedValue(new Error('no browser')) + await expect(signInThroughBrowser()).rejects.toThrow('no browser') + }) + + it('refuses a return without a code', async () => { + vi.mocked(TrekShell.authenticate).mockResolvedValue({ url: 'com.liketrek.trek://auth' }) + await expect(signInThroughBrowser()).rejects.toThrow('without a sign-in code') + expect(nativeAuthApi.exchange).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/native/browserSignIn.ts b/client/src/native/browserSignIn.ts new file mode 100644 index 000000000..029afde1d --- /dev/null +++ b/client/src/native/browserSignIn.ts @@ -0,0 +1,61 @@ +import { sha256 } from '@noble/hashes/sha2' +import { bytesToHex } from '@noble/hashes/utils' +import { nativeAuthApi } from '../api/nativeAuth' +import { APP_URL_SCHEME, TrekShell } from './trekShell' + +/** + * SSO and passkeys cannot run inside the app's WebView: identity providers + * refuse embedded browsers, and WebAuthn wants the app to be associated with + * the server's domain, which no build can be for every server. So the app + * opens the server's /native-handoff page in the system browser, lets the user + * sign in there, and carries the session back with a one-time code. + * + * The verifier never leaves this function. Only its sha256 travels into the + * browser, and the code that comes back through the URL scheme is useless to + * anyone who does not also hold the verifier. The hash is computed in JS + * because crypto.subtle is missing on a plain-http server, which is how many + * home networks reach theirs. + */ + +export const HANDOFF_PATH = '/native-handoff' + +export type BrowserSignInStart = 'oidc' + +export function createHandoffSecret(): { verifier: string; challenge: string } { + const bytes = crypto.getRandomValues(new Uint8Array(32)) + const verifier = bytesToHex(bytes) + return { verifier, challenge: bytesToHex(sha256(new TextEncoder().encode(verifier))) } +} + +export function handoffUrl(origin: string, challenge: string, start?: BrowserSignInStart, invite?: string): string { + const url = new URL(HANDOFF_PATH, origin) + url.searchParams.set('challenge', challenge) + if (start) url.searchParams.set('start', start) + if (invite) url.searchParams.set('invite', invite) + return url.href +} + +export class BrowserSignInCancelled extends Error { + constructor() { + super('Sign-in cancelled') + this.name = 'BrowserSignInCancelled' + } +} + +/** Resolves once the WebView holds a session cookie of its own. */ +export async function signInThroughBrowser(start?: BrowserSignInStart, invite?: string): Promise { + const { verifier, challenge } = createHandoffSecret() + let returned: string + try { + ;({ url: returned } = await TrekShell.authenticate({ + url: handoffUrl(window.location.origin, challenge, start, invite), + callbackScheme: APP_URL_SCHEME, + })) + } catch (err) { + if ((err as { code?: string })?.code === 'CANCELLED') throw new BrowserSignInCancelled() + throw err + } + const code = new URL(returned).searchParams.get('code') + if (!code) throw new Error('The browser returned without a sign-in code') + await nativeAuthApi.exchange({ code, verifier }) +} diff --git a/client/src/native/nativeApp.test.ts b/client/src/native/nativeApp.test.ts new file mode 100644 index 000000000..795d0e11e --- /dev/null +++ b/client/src/native/nativeApp.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { App } from '@capacitor/app' +import { SplashScreen } from '@capacitor/splash-screen' +import { StatusBar, Style } from '@capacitor/status-bar' +import { startNativeApp } from './nativeApp' +import { isNativeApp, isNativeIos } from './platform' +import { keepOfflineShellFresh } from './offlineShell' +import { isEffectivelyOffline } from '../sync/networkMode' + +vi.mock('@capacitor/app', () => ({ App: { addListener: vi.fn(), minimizeApp: vi.fn() } })) +vi.mock('@capacitor/splash-screen', () => ({ SplashScreen: { hide: vi.fn() } })) +vi.mock('@capacitor/status-bar', () => ({ StatusBar: { setStyle: vi.fn() }, Style: { Dark: 'DARK', Light: 'LIGHT' } })) +vi.mock('./platform', () => ({ isNativeApp: vi.fn(), isNativeIos: vi.fn() })) +vi.mock('./offlineShell', () => ({ keepOfflineShellFresh: vi.fn() })) +vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn() })) + +function backButtonHandler(): (event: { canGoBack: boolean }) => void { + const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'backButton') + return call![1] as unknown as (event: { canGoBack: boolean }) => void +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + vi.mocked(isNativeApp).mockReturnValue(true) + vi.mocked(isNativeIos).mockReturnValue(false) + vi.mocked(isEffectivelyOffline).mockReturnValue(false) + vi.mocked(keepOfflineShellFresh).mockResolvedValue() + document.documentElement.classList.remove('dark') +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('startNativeApp', () => { + it('does nothing in a browser', () => { + vi.mocked(isNativeApp).mockReturnValue(false) + startNativeApp() + expect(App.addListener).not.toHaveBeenCalled() + expect(StatusBar.setStyle).not.toHaveBeenCalled() + }) + + it('hides the splash after the first frame', () => { + startNativeApp() + vi.runOnlyPendingTimers() + expect(SplashScreen.hide).toHaveBeenCalled() + }) + + it('walks back through history before it leaves the app', () => { + const back = vi.spyOn(window.history, 'back').mockImplementation(() => {}) + startNativeApp() + backButtonHandler()({ canGoBack: true }) + expect(back).toHaveBeenCalled() + backButtonHandler()({ canGoBack: false }) + expect(App.minimizeApp).toHaveBeenCalled() + }) + + it('keeps the status bar icons readable when the scheme changes', async () => { + startNativeApp() + expect(StatusBar.setStyle).toHaveBeenLastCalledWith({ style: Style.Light }) + document.documentElement.classList.add('dark') + await vi.waitFor(() => expect(StatusBar.setStyle).toHaveBeenLastCalledWith({ style: Style.Dark })) + }) + + it('refreshes the offline start on iOS once the first screen has settled', () => { + vi.mocked(isNativeIos).mockReturnValue(true) + startNativeApp() + expect(keepOfflineShellFresh).not.toHaveBeenCalled() + vi.advanceTimersByTime(15_000) + expect(keepOfflineShellFresh).toHaveBeenCalled() + }) + + it('skips the refresh while offline, and never on Android', () => { + vi.mocked(isNativeIos).mockReturnValue(true) + vi.mocked(isEffectivelyOffline).mockReturnValue(true) + startNativeApp() + vi.advanceTimersByTime(15_000) + expect(keepOfflineShellFresh).not.toHaveBeenCalled() + + vi.mocked(isNativeIos).mockReturnValue(false) + vi.mocked(isEffectivelyOffline).mockReturnValue(false) + startNativeApp() + vi.advanceTimersByTime(15_000) + expect(keepOfflineShellFresh).not.toHaveBeenCalled() + }) + + it('logs a failed refresh instead of throwing', async () => { + vi.mocked(isNativeIos).mockReturnValue(true) + vi.mocked(keepOfflineShellFresh).mockRejectedValue(new Error('down')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + startNativeApp() + vi.advanceTimersByTime(15_000) + await vi.waitFor(() => expect(warn).toHaveBeenCalled()) + }) +}) diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts new file mode 100644 index 000000000..efc4b4121 --- /dev/null +++ b/client/src/native/nativeApp.ts @@ -0,0 +1,49 @@ +import { App } from '@capacitor/app' +import { SplashScreen } from '@capacitor/splash-screen' +import { StatusBar, Style } from '@capacitor/status-bar' +import { isNativeApp, isNativeIos } from './platform' +import { keepOfflineShellFresh } from './offlineShell' +import { isEffectivelyOffline } from '../sync/networkMode' + +/** Long enough that the warm-up never competes with the first screen's requests. */ +const OFFLINE_SHELL_DELAY_MS = 15_000 + +/** + * What the UI does differently when it runs inside the iOS or Android app. + * Everything else is the same UI a browser gets from this server. + */ +export function startNativeApp(): void { + if (!isNativeApp()) return + + followThemeInStatusBar() + + // Android's back gesture: through the app's own history first, and only + // then out of the app, the way a browser tab would behave. + void App.addListener('backButton', ({ canGoBack }) => { + if (canGoBack) window.history.back() + else void App.minimizeApp() + }) + + requestAnimationFrame(() => { void SplashScreen.hide() }) + + if (isNativeIos()) { + window.setTimeout(() => { + if (isEffectivelyOffline()) return + keepOfflineShellFresh().catch((err) => console.warn('[native] offline start page not refreshed', err)) + }, OFFLINE_SHELL_DELAY_MS) + } +} + +/** + * Light icons on the dark scheme and dark icons on the light one. Observing + * the class keeps applyAppearance() the only writer of , and it catches + * every way the scheme changes, including "follow the system". + */ +function followThemeInStatusBar(): void { + const root = document.documentElement + const apply = (): void => { + void StatusBar.setStyle({ style: root.classList.contains('dark') ? Style.Dark : Style.Light }) + } + apply() + new MutationObserver(apply).observe(root, { attributes: true, attributeFilter: ['class'] }) +} diff --git a/client/src/native/nativeFiles.test.ts b/client/src/native/nativeFiles.test.ts new file mode 100644 index 000000000..ddd72ff24 --- /dev/null +++ b/client/src/native/nativeFiles.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Directory, Filesystem } from '@capacitor/filesystem' +import { Share } from '@capacitor/share' +import { cacheFileName, fileNameFromUrl, shareFile } from './nativeFiles' + +vi.mock('@capacitor/filesystem', () => ({ Directory: { Cache: 'CACHE' }, Filesystem: { writeFile: vi.fn() } })) +vi.mock('@capacitor/share', () => ({ Share: { share: vi.fn() } })) + +beforeEach(() => { + vi.mocked(Filesystem.writeFile).mockReset().mockResolvedValue({ uri: 'file:///cache/trip.pdf' }) + vi.mocked(Share.share).mockReset().mockResolvedValue({}) +}) + +describe('cacheFileName', () => { + it('keeps a readable name but nothing that could leave the cache directory', () => { + expect(cacheFileName('Lisbon 2026.pdf')).toBe('Lisbon 2026.pdf') + expect(cacheFileName('../../etc/passwd')).toBe('_.._etc_passwd') + expect(cacheFileName('a:b*c?"d"|f\u0001.txt')).toBe('a_b_c__d__e__f_.txt') + expect(cacheFileName('...hidden')).toBe('hidden') + expect(cacheFileName(' ')).toBe('download') + }) +}) + +describe('fileNameFromUrl', () => { + it('takes the decoded last path segment', () => { + expect(fileNameFromUrl('/api/trips/1/files/42/Boarding%20pass.pdf?token=x')).toBe('Boarding pass.pdf') + expect(fileNameFromUrl('/uploads/files/')).toBe('files') + expect(fileNameFromUrl('/bad/%E0%A4%A')).toBe('%E0%A4%A') + }) +}) + +describe('shareFile', () => { + it('writes the blob to the cache and hands it to the share sheet', async () => { + await shareFile(new Blob(['hello'], { type: 'text/plain' }), 'note.txt') + + expect(Filesystem.writeFile).toHaveBeenCalledWith({ path: 'note.txt', data: btoa('hello'), directory: Directory.Cache }) + expect(Share.share).toHaveBeenCalledWith({ files: ['file:///cache/trip.pdf'] }) + }) + + it('treats a dismissed sheet as the user changing their mind', async () => { + vi.mocked(Share.share).mockRejectedValue(new Error('Share canceled')) + await expect(shareFile(new Blob(['x']), 'x.txt')).resolves.toBeUndefined() + }) + + it('passes a real failure on', async () => { + vi.mocked(Share.share).mockRejectedValue(new Error('No activity found')) + await expect(shareFile(new Blob(['x']), 'x.txt')).rejects.toThrow('No activity found') + }) +}) diff --git a/client/src/native/nativeFiles.ts b/client/src/native/nativeFiles.ts new file mode 100644 index 000000000..260549c31 --- /dev/null +++ b/client/src/native/nativeFiles.ts @@ -0,0 +1,46 @@ +import { Directory, Filesystem } from '@capacitor/filesystem' +import { Share } from '@capacitor/share' + +function toBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result).slice(String(reader.result).indexOf(',') + 1)) + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(blob) + }) +} + +/** Readable, but safe as a single path segment inside the cache directory. */ +export function cacheFileName(filename: string): string { + const cleaned = Array.from(filename, (ch) => (ch.charCodeAt(0) < 32 || '\\/:*?"<>|'.includes(ch) ? '_' : ch)).join('').trim() + return cleaned.replace(/^\.+/, '') || 'download' +} + +export function fileNameFromUrl(url: string): string { + const last = url.split('?')[0]!.split('/').filter(Boolean).pop() ?? '' + try { + return decodeURIComponent(last) + } catch { + return last + } +} + +/** + * The app's WebView cannot save a download on its own, so the file goes into + * the app's cache and from there into the system share sheet, which offers + * saving it to Files, opening it in another app or sending it on. + */ +export async function shareFile(blob: Blob, filename: string): Promise { + const { uri } = await Filesystem.writeFile({ + path: cacheFileName(filename), + data: await toBase64(blob), + directory: Directory.Cache, + }) + try { + await Share.share({ files: [uri] }) + } catch (err) { + // Dismissing the sheet rejects; that is the user's choice, not a failure. + if (/cancel/i.test(String((err as Error)?.message))) return + throw err + } +} diff --git a/client/src/native/offlineShell.test.ts b/client/src/native/offlineShell.test.ts new file mode 100644 index 000000000..ef3c55c70 --- /dev/null +++ b/client/src/native/offlineShell.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { keepOfflineShellFresh, manifestFiles } from './offlineShell' +import { TrekShell } from './trekShell' + +vi.mock('./trekShell', () => ({ TrekShell: { refreshOfflineCopy: vi.fn() } })) + +const manifest = { + 'index.html': { file: 'assets/index-a1b2c3d4.js', css: ['assets/index-e5f6a7b8.css'], assets: ['assets/poppins-11223344.woff2'] }, + 'src/pages/AtlasPage.tsx': { file: 'assets/AtlasPage-99887766.js', css: ['assets/index-e5f6a7b8.css'] }, + _shared: { file: 'assets/shared-55443322.js', assets: 'not-a-list' }, + broken: null, +} + +beforeEach(() => { + vi.mocked(TrekShell.refreshOfflineCopy).mockReset().mockResolvedValue({ saved: true }) +}) + +describe('manifestFiles', () => { + it('collects every chunk, stylesheet and asset once', () => { + expect(manifestFiles(manifest).sort()).toEqual([ + 'assets/AtlasPage-99887766.js', + 'assets/index-a1b2c3d4.js', + 'assets/index-e5f6a7b8.css', + 'assets/poppins-11223344.woff2', + 'assets/shared-55443322.js', + ]) + }) + + it('returns nothing for something that is not a manifest', () => { + expect(manifestFiles(null)).toEqual([]) + expect(manifestFiles('x')).toEqual([]) + }) +}) + +describe('keepOfflineShellFresh', () => { + it('refreshes the stored start page and fetches every file of the build', async () => { + const fetched: string[] = [] + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url === '/asset-manifest.json') { + expect(init).toEqual({ cache: 'no-store' }) + return new Response(JSON.stringify(manifest)) + } + fetched.push(url) + if (url.includes('shared')) throw new Error('offline for a moment') + return new Response('x') + }) as unknown as typeof fetch + + await keepOfflineShellFresh(fetchImpl) + + expect(TrekShell.refreshOfflineCopy).toHaveBeenCalled() + expect(fetched.sort()).toEqual([ + '/assets/AtlasPage-99887766.js', + '/assets/index-a1b2c3d4.js', + '/assets/index-e5f6a7b8.css', + '/assets/poppins-11223344.woff2', + '/assets/shared-55443322.js', + ]) + }) + + it('stops after the start page when the build has no manifest', async () => { + const fetchImpl = vi.fn(async () => new Response('', { status: 404 })) as unknown as typeof fetch + await keepOfflineShellFresh(fetchImpl) + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) +}) diff --git a/client/src/native/offlineShell.ts b/client/src/native/offlineShell.ts new file mode 100644 index 000000000..8fb95355f --- /dev/null +++ b/client/src/native/offlineShell.ts @@ -0,0 +1,51 @@ +import { TrekShell } from './trekShell' + +/** + * iOS only. WKWebView runs no service worker for a domain the app does not + * list at build time, so the app stands in for it: the native side keeps a + * copy of the start page (TrekShellPlugin.refreshOfflineCopy), and this pulls + * every chunk of the current build into WebKit's HTTP cache. The server marks + * them immutable, so a cold start without network can load the stored start + * page under the server's origin and find all its scripts, including the lazy + * routes nobody has opened yet. + */ + +const MANIFEST_URL = '/asset-manifest.json' +const CONCURRENCY = 4 + +interface ManifestChunk { + file?: string + css?: string[] + assets?: string[] +} + +export function manifestFiles(manifest: unknown): string[] { + if (typeof manifest !== 'object' || manifest === null) return [] + const files = new Set() + for (const chunk of Object.values(manifest as Record)) { + if (typeof chunk?.file === 'string') files.add(chunk.file) + for (const list of [chunk?.css, chunk?.assets]) { + if (Array.isArray(list)) list.forEach((f) => typeof f === 'string' && files.add(f)) + } + } + return [...files] +} + +async function warmCache(files: string[], fetchFile: (path: string) => Promise): Promise { + const queue = [...files] + const worker = async (): Promise => { + for (let path = queue.shift(); path !== undefined; path = queue.shift()) { + // One missing chunk must not stop the rest; the next launch tries again. + await fetchFile(`/${path}`).catch(() => undefined) + } + } + await Promise.all(Array.from({ length: CONCURRENCY }, worker)) +} + +export async function keepOfflineShellFresh(fetchImpl: typeof fetch = fetch): Promise { + await TrekShell.refreshOfflineCopy() + const response = await fetchImpl(MANIFEST_URL, { cache: 'no-store' }) + if (!response.ok) return + const files = manifestFiles(await response.json()) + await warmCache(files, (path) => fetchImpl(path).then((r) => r.blob())) +} diff --git a/client/src/native/platform.ts b/client/src/native/platform.ts new file mode 100644 index 000000000..af0adddf5 --- /dev/null +++ b/client/src/native/platform.ts @@ -0,0 +1,15 @@ +import { Capacitor } from '@capacitor/core' + +/** True inside the iOS and Android apps, where the WebView shows this server's UI. */ +export function isNativeApp(): boolean { + return Capacitor.isNativePlatform() +} + +export function isNativeIos(): boolean { + return isNativeApp() && Capacitor.getPlatform() === 'ios' +} + +/** The server the app is connected to: the WebView sits on its origin. */ +export function nativeServerHost(): string | null { + return isNativeApp() ? window.location.host : null +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 9c8dff1b3..52789744d 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -16,7 +16,6 @@ import ToggleSwitch from '../components/Settings/ToggleSwitch'; import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'; import { useLogin } from './login/useLogin'; import LoginWorld from './login/LoginWorld'; -import { clearSignedOut } from '../utils/signedOut' /** Fixed so the sky does not reshuffle on every render. */ const STARFIELD = [ @@ -82,6 +81,9 @@ export default function LoginPage(): React.ReactElement { handleDemoLogin, handleSubmit, handlePasskeyLogin, + nativeApp, + handleSsoClick, + changeServer, } = useLogin(); const oidcButtonShown = !!(appConfig?.oidc_configured && appConfig?.oidc_login && !oidcOnly); @@ -497,7 +499,7 @@ export default function LoginPage(): React.ReactElement {
)} )} + + {nativeApp && ( + + )} diff --git a/client/src/pages/NativeHandoffPage.test.tsx b/client/src/pages/NativeHandoffPage.test.tsx new file mode 100644 index 000000000..13297c298 --- /dev/null +++ b/client/src/pages/NativeHandoffPage.test.tsx @@ -0,0 +1,53 @@ +// FE-NATIVE-HANDOFF-PAGE-001 to FE-NATIVE-HANDOFF-PAGE-004 +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '../../tests/helpers/render' +import NativeHandoffPage from './NativeHandoffPage' +import { useNativeHandoff } from './nativeHandoff/useNativeHandoff' + +vi.mock('./nativeHandoff/useNativeHandoff', () => ({ useNativeHandoff: vi.fn() })) + +const base = { + valid: true, + ready: true, + userName: 'maria', + status: 'idle' as const, + openApp: vi.fn(), + switchAccount: vi.fn(), +} + +beforeEach(() => { + vi.mocked(useNativeHandoff).mockReturnValue({ ...base }) +}) + +describe('NativeHandoffPage', () => { + it('FE-NATIVE-HANDOFF-PAGE-001: asks before handing the session to the app', () => { + render() + expect(screen.getByText('You are signed in as maria. Open the TREK app to finish signing in there.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Open the TREK app' })) + expect(base.openApp).toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Use a different account' })) + expect(base.switchAccount).toHaveBeenCalled() + }) + + it('FE-NATIVE-HANDOFF-PAGE-002: shows the failure and keeps the button while sending disabled', () => { + vi.mocked(useNativeHandoff).mockReturnValue({ ...base, status: 'failed' }) + const { rerender } = render() + expect(screen.getByRole('alert')).toHaveTextContent('Signing in did not work. Please try again.') + + vi.mocked(useNativeHandoff).mockReturnValue({ ...base, status: 'sending' }) + rerender() + expect(screen.getByRole('button', { name: 'Open the TREK app' })).toBeDisabled() + }) + + it('FE-NATIVE-HANDOFF-PAGE-003: spins while the session is being checked', () => { + vi.mocked(useNativeHandoff).mockReturnValue({ ...base, ready: false }) + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('FE-NATIVE-HANDOFF-PAGE-004: says so when the link is broken', () => { + vi.mocked(useNativeHandoff).mockReturnValue({ ...base, valid: false, ready: false }) + render() + expect(screen.getByText('Signing in did not work. Please try again.')).toBeInTheDocument() + }) +}) diff --git a/client/src/pages/NativeHandoffPage.tsx b/client/src/pages/NativeHandoffPage.tsx new file mode 100644 index 000000000..db8f73bfd --- /dev/null +++ b/client/src/pages/NativeHandoffPage.tsx @@ -0,0 +1,38 @@ +import { Smartphone } from 'lucide-react' +import { useTranslation } from '../i18n' +import { useNativeHandoff } from './nativeHandoff/useNativeHandoff' + +export default function NativeHandoffPage() { + const { t } = useTranslation() + const { valid, ready, userName, status, openApp, switchAccount } = useNativeHandoff() + + return ( +
+
+ + {!valid ? ( +

{t('native.login.failed')}

+ ) : !ready ? ( +
+ ) : ( + <> +

{t('native.handoff.title')}

+

{t('native.handoff.hint', { name: userName })}

+ {status === 'failed' &&

{t('native.login.failed')}

} + + + + )} +
+
+ ) +} diff --git a/client/src/pages/login/useLogin.native.test.tsx b/client/src/pages/login/useLogin.native.test.tsx new file mode 100644 index 000000000..136f97353 --- /dev/null +++ b/client/src/pages/login/useLogin.native.test.tsx @@ -0,0 +1,137 @@ +// FE-LOGIN-NATIVE-001 to FE-LOGIN-NATIVE-008: useLogin inside the iOS/Android app +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act, waitFor } from '../../../tests/helpers/render'; +import { MemoryRouter } from 'react-router'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import { resetAllStores } from '../../../tests/helpers/store'; +import { buildAppConfig } from '../../../tests/helpers/factories'; +import { TranslationProvider } from '../../i18n/TranslationContext'; +import { useAuthStore } from '../../store/authStore'; +import { startAuthentication } from '@simplewebauthn/browser'; +import { markSignedOut, wasSignedOut } from '../../utils/signedOut'; +import { BrowserSignInCancelled, signInThroughBrowser } from '../../native/browserSignIn'; +import { TrekShell } from '../../native/trekShell'; +import { useLogin } from './useLogin'; + +// Stable across renders: useLogin's config effect depends on navigate. +const mockNavigate = vi.fn(); +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return { ...actual, useNavigate: () => mockNavigate }; +}); +vi.mock('@simplewebauthn/browser', () => ({ startAuthentication: vi.fn() })); +vi.mock('../../native/platform', () => ({ isNativeApp: () => true })); +vi.mock('../../native/browserSignIn', async () => { + const actual = await vi.importActual('../../native/browserSignIn'); + return { ...actual, signInThroughBrowser: vi.fn() }; +}); +vi.mock('../../native/trekShell', () => ({ TrekShell: { resetServer: vi.fn() } })); + +const realLocation = window.location; +const loadUser = vi.fn(async () => {}); + +function renderLogin() { + return renderHook(() => useLogin(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); +} + +function clickEvent() { + return { preventDefault: vi.fn() } as unknown as React.MouseEvent; +} + +beforeEach(() => { + resetAllStores(); + vi.mocked(signInThroughBrowser).mockReset(); + vi.mocked(startAuthentication).mockReset(); + vi.mocked(TrekShell.resetServer).mockReset(); + loadUser.mockClear(); + useAuthStore.setState({ loadUser }); + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { ...realLocation, pathname: '/login', href: 'http://localhost/login', search: '' }, + }); +}); + +afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(window, 'location', { configurable: true, writable: true, value: realLocation }); +}); + +describe('useLogin in the native app', () => { + it('FE-LOGIN-NATIVE-001: reports that it runs in the app', async () => { + const { result } = renderLogin(); + await waitFor(() => expect(result.current.appConfig).not.toBeNull()); + expect(result.current.nativeApp).toBe(true); + }); + + it('FE-LOGIN-NATIVE-002: does not bounce an OIDC-only install to the IdP inside the WebView', async () => { + server.use( + http.get('/api/auth/app-config', () => + HttpResponse.json(buildAppConfig({ password_login: false, oidc_configured: true, oidc_login: true })), + ), + ); + const { result } = renderLogin(); + await waitFor(() => expect(result.current.appConfig).not.toBeNull()); + expect(window.location.href).toBe('http://localhost/login'); + expect(result.current.oidcOnly).toBe(true); + }); + + it('FE-LOGIN-NATIVE-003: routes the SSO link through the system browser and signs in', async () => { + vi.mocked(signInThroughBrowser).mockResolvedValue(); + markSignedOut(); + const { result } = renderLogin(); + const event = clickEvent(); + + await act(async () => { result.current.handleSsoClick(event); }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(wasSignedOut()).toBe(false); + expect(signInThroughBrowser).toHaveBeenCalledWith('oidc'); + await waitFor(() => expect(loadUser).toHaveBeenCalledWith({ silent: true })); + await waitFor(() => expect(result.current.showTakeoff).toBe(true)); + }); + + it('FE-LOGIN-NATIVE-004: sends the passkey button to the browser instead of WebAuthn', async () => { + vi.mocked(signInThroughBrowser).mockResolvedValue(); + const { result } = renderLogin(); + + await act(async () => { await result.current.handlePasskeyLogin(); }); + + expect(signInThroughBrowser).toHaveBeenCalledWith(undefined); + expect(startAuthentication).not.toHaveBeenCalled(); + }); + + it('FE-LOGIN-NATIVE-005: stays quiet when the user closes the browser', async () => { + vi.mocked(signInThroughBrowser).mockRejectedValue(new BrowserSignInCancelled()); + const { result } = renderLogin(); + + await act(async () => { await result.current.handlePasskeyLogin(); }); + + expect(result.current.error).toBe(''); + expect(result.current.isLoading).toBe(false); + }); + + it('FE-LOGIN-NATIVE-006: explains a failed hand-off', async () => { + vi.mocked(signInThroughBrowser).mockRejectedValue(new Error('exchange failed')); + const { result } = renderLogin(); + + await act(async () => { await result.current.handlePasskeyLogin(); }); + + expect(result.current.error).toBe('Signing in did not work. Please try again.'); + expect(result.current.isLoading).toBe(false); + }); + + it('FE-LOGIN-NATIVE-007: offers the way back to the address screen', () => { + const { result } = renderLogin(); + act(() => { result.current.changeServer(); }); + expect(TrekShell.resetServer).toHaveBeenCalled(); + }); +}); diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index 93238361d..2fa48ed4e 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -4,10 +4,13 @@ import { useAuthStore } from '../../store/authStore' import { useSettingsStore, hasStoredLanguage } from '../../store/settingsStore' import { useTranslation, detectBrowserLanguage } from '../../i18n' import { startAuthentication } from '@simplewebauthn/browser' -import { wasSignedOut } from '../../utils/signedOut' +import { clearSignedOut, wasSignedOut } from '../../utils/signedOut' import { authApi, configApi } from '../../api/client' import { getApiErrorMessage } from '../../types' import { START_DESTINATION_ROUTE } from '../../utils/startDestination' +import { isNativeApp } from '../../native/platform' +import { BrowserSignInCancelled, signInThroughBrowser, type BrowserSignInStart } from '../../native/browserSignIn' +import { TrekShell } from '../../native/trekShell' interface AppConfig { has_users: boolean @@ -51,6 +54,7 @@ export function useLogin() { const [inviteToken, setInviteToken] = useState('') const [inviteValid, setInviteValid] = useState(false) const exchangeInitiated = useRef(false) + const [nativeApp] = useState(isNativeApp) const [langDropdownOpen, setLangDropdownOpen] = useState(false) @@ -185,14 +189,16 @@ export function useLogin() { if (!config.has_users) setMode('register') // Skip auto-redirect when config is from cache — network is unreliable // and auto-redirecting to the IdP could loop if the proxy changed. - if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { + // Not in the app: its WebView cannot host the IdP, and opening the + // system browser uninvited on every launch would be worse than a button. + if (!fromCache && !nativeApp && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { // No switch to consult on this path: OIDC-only always asks for the // remembered lifetime, matching the SSO button on the panel (#1927). window.location.href = '/api/auth/oidc/login?remember=1' } } }) - }, [navigate, t, noRedirect]) + }, [navigate, t, noRedirect, nativeApp]) // Language detection chain (runs once on mount, only if user has no saved preference): // 1. localStorage → already in store initial state, skip @@ -233,7 +239,31 @@ export function useLogin() { } } + /** In the app: SSO and passkeys run in the system browser (native/browserSignIn.ts). */ + const handleBrowserSignIn = async (start?: BrowserSignInStart): Promise => { + setError('') + setIsLoading(true) + try { + await signInThroughBrowser(start) + await loadUser({ silent: true }) + takeOff() + } catch (err: unknown) { + if (!(err instanceof BrowserSignInCancelled)) setError(t('native.login.failed')) + setIsLoading(false) + } + } + + const handleSsoClick = (e: React.MouseEvent): void => { + clearSignedOut() + if (!nativeApp) return + e.preventDefault() + void handleBrowserSignIn('oidc') + } + + const changeServer = (): void => { void TrekShell.resetServer() } + const handlePasskeyLogin = async (): Promise => { + if (nativeApp) return handleBrowserSignIn() setError('') setIsLoading(true) try { @@ -334,5 +364,6 @@ export function useLogin() { passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword, noRedirect, showRegisterOption, oidcOnly, handleDemoLogin, handleSubmit, handlePasskeyLogin, + nativeApp, handleSsoClick, changeServer, } } diff --git a/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx b/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx new file mode 100644 index 000000000..438e6f30c --- /dev/null +++ b/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx @@ -0,0 +1,119 @@ +// FE-NATIVE-HANDOFF-001 to FE-NATIVE-HANDOFF-008 +import React from 'react' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act, waitFor } from '../../../tests/helpers/render' +import { MemoryRouter } from 'react-router' +import { resetAllStores } from '../../../tests/helpers/store' +import { useAuthStore } from '../../store/authStore' +import { nativeAuthApi } from '../../api/nativeAuth' +import { useNativeHandoff } from './useNativeHandoff' + +const mockNavigate = vi.fn() +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router') + return { ...actual, useNavigate: () => mockNavigate } +}) +vi.mock('../../api/nativeAuth', () => ({ nativeAuthApi: { handoff: vi.fn() } })) + +const challenge = 'ab'.repeat(32) +const realLocation = window.location +const logout = vi.fn(async () => {}) + +function setSearch(search: string) { + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { ...realLocation, search, href: 'http://localhost/native-handoff' + search, replace: vi.fn() }, + }) +} + +function renderHandoff() { + return renderHook(() => useNativeHandoff(), { + wrapper: ({ children }: { children: React.ReactNode }) => {children}, + }) +} + +beforeEach(() => { + resetAllStores() + mockNavigate.mockClear() + logout.mockClear() + vi.mocked(nativeAuthApi.handoff).mockReset() + sessionStorage.clear() + setSearch(`?challenge=${challenge}`) + useAuthStore.setState({ isLoading: false, isAuthenticated: true, user: { id: 1, username: 'maria' } as never, logout }) +}) + +afterEach(() => { + Object.defineProperty(window, 'location', { configurable: true, writable: true, value: realLocation }) +}) + +describe('useNativeHandoff', () => { + it('FE-NATIVE-HANDOFF-001: is ready for a signed-in user with a valid challenge', () => { + const { result } = renderHandoff() + expect(result.current.valid).toBe(true) + expect(result.current.ready).toBe(true) + expect(result.current.userName).toBe('maria') + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('FE-NATIVE-HANDOFF-002: rejects a malformed challenge and does nothing with it', () => { + setSearch('?challenge=nope') + useAuthStore.setState({ isAuthenticated: false }) + const { result } = renderHandoff() + expect(result.current.valid).toBe(false) + expect(result.current.ready).toBe(false) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('FE-NATIVE-HANDOFF-003: sends a visitor to the login page and back here afterwards', () => { + useAuthStore.setState({ isAuthenticated: false }) + renderHandoff() + expect(mockNavigate).toHaveBeenCalledWith( + `/login?redirect=${encodeURIComponent(`/native-handoff?challenge=${challenge}`)}`, + { replace: true }, + ) + }) + + it('FE-NATIVE-HANDOFF-004: starts SSO straight away when the app asked for it, without looping', () => { + setSearch(`?challenge=${challenge}&start=oidc`) + useAuthStore.setState({ isAuthenticated: false }) + renderHandoff() + expect(window.location.replace).toHaveBeenCalledWith('/api/auth/oidc/login?remember=1') + expect(sessionStorage.getItem('oidc_redirect')).toBe(`/native-handoff?challenge=${challenge}`) + }) + + it('FE-NATIVE-HANDOFF-005: waits while the session is still being checked', () => { + useAuthStore.setState({ isAuthenticated: false, isLoading: true }) + const { result } = renderHandoff() + expect(result.current.ready).toBe(false) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('FE-NATIVE-HANDOFF-006: only mints the code on the tap, then hands it to the app scheme', async () => { + vi.mocked(nativeAuthApi.handoff).mockResolvedValue({ code: 'c/1' }) + const { result } = renderHandoff() + expect(nativeAuthApi.handoff).not.toHaveBeenCalled() + + await act(async () => { await result.current.openApp() }) + + expect(nativeAuthApi.handoff).toHaveBeenCalledWith(challenge) + expect(window.location.href).toBe('com.liketrek.trek://auth?code=c%2F1') + }) + + it('FE-NATIVE-HANDOFF-007: reports a failed hand-off', async () => { + vi.mocked(nativeAuthApi.handoff).mockRejectedValue(new Error('401')) + const { result } = renderHandoff() + await act(async () => { await result.current.openApp() }) + expect(result.current.status).toBe('failed') + }) + + it('FE-NATIVE-HANDOFF-008: lets the user switch accounts and come back', async () => { + const { result } = renderHandoff() + await act(async () => { await result.current.switchAccount() }) + expect(logout).toHaveBeenCalled() + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith( + `/login?redirect=${encodeURIComponent(`/native-handoff?challenge=${challenge}`)}`, + { replace: true }, + )) + }) +}) diff --git a/client/src/pages/nativeHandoff/useNativeHandoff.ts b/client/src/pages/nativeHandoff/useNativeHandoff.ts new file mode 100644 index 000000000..01b4b52f4 --- /dev/null +++ b/client/src/pages/nativeHandoff/useNativeHandoff.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' +import { useShallow } from 'zustand/react/shallow' +import { useAuthStore } from '../../store/authStore' +import { nativeAuthApi } from '../../api/nativeAuth' +import { APP_URL_SCHEME } from '../../native/trekShell' +import { HANDOFF_PATH } from '../../native/browserSignIn' + +const CHALLENGE = /^[0-9a-f]{64}$/ + +/** + * The system-browser end of the app's sign-in (see native/browserSignIn.ts). + * Signs the user in here if needed, then hands the session to the app only + * after an explicit tap: a link to this page with somebody else's challenge + * must not be able to pass a session on silently. + */ +export function useNativeHandoff() { + const navigate = useNavigate() + const { isAuthenticated, isLoading, userName, logout } = useAuthStore(useShallow((s) => ({ + isAuthenticated: s.isAuthenticated, + isLoading: s.isLoading, + userName: s.user?.username ?? '', + logout: s.logout, + }))) + const [params] = useState(() => new URLSearchParams(window.location.search)) + const challenge = params.get('challenge') ?? '' + const valid = CHALLENGE.test(challenge) + const [status, setStatus] = useState<'idle' | 'sending' | 'failed'>('idle') + + // Back here once signed in, without `start`, so an SSO round trip cannot loop. + const returnPath = `${HANDOFF_PATH}?challenge=${challenge}` + + useEffect(() => { + if (!valid || isLoading || isAuthenticated) return + if (params.get('start') === 'oidc') { + sessionStorage.setItem('oidc_redirect', returnPath) + window.location.replace('/api/auth/oidc/login?remember=1') + return + } + navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true }) + }, [valid, isLoading, isAuthenticated, params, returnPath, navigate]) + + const openApp = async (): Promise => { + setStatus('sending') + try { + const { code } = await nativeAuthApi.handoff(challenge) + window.location.href = `${APP_URL_SCHEME}://auth?code=${encodeURIComponent(code)}` + } catch { + setStatus('failed') + } + } + + const switchAccount = async (): Promise => { + await logout() + navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true }) + } + + return { + valid, + ready: valid && !isLoading && isAuthenticated, + userName, + status, + openApp, + switchAccount, + } +} diff --git a/client/src/utils/fileDownload.ts b/client/src/utils/fileDownload.ts index 96ae0cbb6..1957545e9 100644 --- a/client/src/utils/fileDownload.ts +++ b/client/src/utils/fileDownload.ts @@ -1,5 +1,7 @@ import { getCachedBlob } from '../db/offlineDb' import { isEffectivelyOffline } from '../sync/networkMode' +import { isNativeApp } from '../native/platform' +import { fileNameFromUrl, shareFile } from '../native/nativeFiles' // MIME types safe to open inline (will not execute script in any browser). // Everything else (text/html, image/svg+xml, text/javascript, …) is forced to @@ -80,6 +82,10 @@ async function getFileBlob(url: string): Promise { * in the same tick can abort the download in other browsers. */ export function downloadBlob(blob: Blob, filename: string): void { + if (isNativeApp()) { + shareFile(blob, filename).catch((err) => console.error('[download]', err)) + return + } const blobUrl = URL.createObjectURL(blob) triggerAnchorDownload(blobUrl, filename) } @@ -93,6 +99,7 @@ export function downloadBlob(blob: Blob, filename: string): void { */ export async function downloadFile(url: string, filename?: string): Promise { const blob = await getFileBlob(url) + if (isNativeApp()) return shareFile(blob, filename ?? fileNameFromUrl(url)) const blobUrl = URL.createObjectURL(blob) triggerAnchorDownload(blobUrl, filename) } @@ -116,6 +123,9 @@ export async function downloadFile(url: string, filename?: string): Promise { const blob = await getFileBlob(url) + // The share sheet previews PDFs and images too, and a blob tab has nowhere + // to open inside the app. + if (isNativeApp()) return shareFile(blob, filename ?? fileNameFromUrl(url)) const blobUrl = URL.createObjectURL(blob) // Force download for MIME types that can execute script when rendered inline diff --git a/client/tests/unit/utils/fileDownload.native.test.ts b/client/tests/unit/utils/fileDownload.native.test.ts new file mode 100644 index 000000000..cec531875 --- /dev/null +++ b/client/tests/unit/utils/fileDownload.native.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { downloadBlob, downloadFile, openFile } from '../../../src/utils/fileDownload' +import { shareFile } from '../../../src/native/nativeFiles' + +vi.mock('../../../src/db/offlineDb', () => ({ getCachedBlob: vi.fn() })) +vi.mock('../../../src/native/platform', () => ({ isNativeApp: () => true })) +vi.mock('../../../src/native/nativeFiles', async () => { + const actual = await vi.importActual('../../../src/native/nativeFiles') + return { ...actual, shareFile: vi.fn() } +}) + +const pdf = new Blob(['%PDF'], { type: 'application/pdf' }) + +beforeEach(() => { + vi.mocked(shareFile).mockReset().mockResolvedValue() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, blob: () => Promise.resolve(pdf) })) +}) + +describe('fileDownload inside the native app', () => { + it('hands an in-memory blob to the share sheet instead of an anchor', () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click') + downloadBlob(pdf, 'report.pdf') + expect(shareFile).toHaveBeenCalledWith(pdf, 'report.pdf') + expect(click).not.toHaveBeenCalled() + }) + + it('logs a share that failed rather than losing it', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(shareFile).mockRejectedValue(new Error('no space')) + downloadBlob(pdf, 'report.pdf') + await vi.waitFor(() => expect(error).toHaveBeenCalledWith('[download]', expect.any(Error))) + }) + + it('shares a protected file under its given name, or the one from its URL', async () => { + await downloadFile('/api/trips/1/files/2/download', 'Boarding pass.pdf') + expect(shareFile).toHaveBeenLastCalledWith(pdf, 'Boarding pass.pdf') + + await downloadFile('/uploads/files/ticket%20A.pdf') + expect(shareFile).toHaveBeenLastCalledWith(pdf, 'ticket A.pdf') + }) + + it('opens files through the share sheet too, since there is no tab to open them in', async () => { + await openFile('/api/trips/1/files/2/download') + expect(shareFile).toHaveBeenLastCalledWith(pdf, 'download') + }) +}) diff --git a/client/vite.config.js b/client/vite.config.js index 0be595f4d..0cb17d930 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -265,6 +265,10 @@ export default defineConfig(({ mode }) => ({ // is, so a toolchain bump can't silently change which browsers still work. target: 'es2022', sourcemap: false, + // Served as /asset-manifest.json. The iOS app, which runs no service worker, + // walks it to pull every chunk into WebKit's cache so TREK can start offline + // (src/native/offlineShell.ts). + manifest: 'asset-manifest.json', modulePreload: { polyfill: true }, // Vite 8 bundles with rolldown, not rollup. `rollupOptions` is only an alias // onto `rolldownOptions`, and both `manualChunks` and `advancedChunks` are diff --git a/package-lock.json b/package-lock.json index 95c86a4d5..4deaa01cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@fontsource/poppins": "^5.2.7", "@mapbox/mapbox-gl-rtl-text": "^0.4.0", "@maplibre/maplibre-gl-leaflet": "0.1.4", + "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@trek/shared": "*", "axios": "^1.6.7", @@ -6440,7 +6441,6 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "devOptional": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16"