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.
This commit is contained in:
Maurice
2026-09-24 12:42:34 +02:00
parent c5765bbf15
commit ae0d4e4520
37 changed files with 1153 additions and 88 deletions
+66
View File
@@ -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)
})
})