feat(native): the apps load the server's own UI

The apps ship only an address screen now. Once a server is picked, the
native side points the WebView at it: TrekBridgeViewController on iOS and a
config copy with server.url on Android. The UI then comes from that server,
in the version it runs and on its own origin, so the session cookie,
uploads, /ws and plugins work exactly as in a browser and no app release
has to keep pace with the servers out there.

The address screen (native-shell/, built into native-dist/) probes
/api/health/features through native HTTP, tries https before http when no
scheme was typed, and refuses servers without the nativeApp flag. It also
serves as the error page when the remembered server does not load: retry,
change server, or on iOS the stored copy of the server's start page.

TrekShell is the app's own plugin for all of that: remember, change and
reload the server, sign in through ASWebAuthenticationSession or a Custom
Tab, and on iOS keep and open the offline copy of the start page, because
WKWebView runs no service worker for a domain the app does not list.

Plugins the frame does not use are gone (push, camera, geolocation,
calendar, file transfer, in-app browser and more): the WebView covers the
camera and location itself, and push needs a relay before it is worth
shipping. With them go the push entitlement, the calendar usage strings
and the Firebase bits. The iOS scheme falls back to capacitor://, since
Capacitor drops an iosScheme of https, and the arm64 capability replaces
armv7.
This commit is contained in:
Maurice
2026-09-24 12:30:32 +02:00
parent 9832d12751
commit c5765bbf15
79 changed files with 1600 additions and 448 deletions
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from 'vitest'
import { candidateUrls, probeServer, type ProbeResponse } from './serverAddress'
const features = (url: string, data: unknown = { bookingImport: false, aiParsing: false, nativeApp: true }): ProbeResponse =>
({ status: 200, data, url })
describe('candidateUrls', () => {
it('tries https before http when no scheme was typed', () => {
expect(candidateUrls('trek.example.com')).toEqual(['https://trek.example.com', 'http://trek.example.com'])
expect(candidateUrls(' 192.168.1.10:3001/ ')).toEqual(['https://192.168.1.10:3001', 'http://192.168.1.10:3001'])
})
it('keeps an explicit scheme and path, without query, hash or trailing slashes', () => {
expect(candidateUrls('http://nas.local:3001/trek/?x=1#y')).toEqual(['http://nas.local:3001/trek'])
expect(candidateUrls('HTTPS://Trek.Example.com//')).toEqual(['https://trek.example.com'])
})
it('returns nothing for empty input or other schemes', () => {
expect(candidateUrls(' ')).toEqual([])
expect(candidateUrls('ftp://trek.example.com')).toEqual([])
expect(candidateUrls('javascript://alert(1)')).toEqual([])
})
})
describe('probeServer', () => {
it('accepts a server that reports nativeApp and follows its redirect', async () => {
const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features'))
expect(await probeServer('http://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' })
expect(get).toHaveBeenCalledWith('http://trek.example.com/api/health/features')
})
it('falls back to the candidate itself when the final URL is not the probe path', async () => {
const get = vi.fn().mockResolvedValue(features('https://sso.example.com/login'))
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' })
})
it('moves on to plain http when https cannot be reached', async () => {
const get = vi.fn()
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
.mockResolvedValueOnce(features('http://192.168.1.10:3001/api/health/features'))
expect(await probeServer('192.168.1.10:3001', get)).toEqual({ ok: true, url: 'http://192.168.1.10:3001' })
expect(get).toHaveBeenNthCalledWith(2, 'http://192.168.1.10:3001/api/health/features')
})
it('names a TREK server without the native flag as too old', async () => {
const get = vi.fn().mockResolvedValue(features('https://old.example.com/api/health/features', { bookingImport: true, aiParsing: false }))
expect(await probeServer('https://old.example.com', get)).toEqual({ ok: false, reason: 'tooOld' })
})
it('tells a server that is not TREK apart from one that is not there', async () => {
const html = vi.fn().mockResolvedValue({ status: 200, data: '<html>', url: 'https://x.example.com/api/health/features' })
expect(await probeServer('https://x.example.com', html)).toEqual({ ok: false, reason: 'notTrek' })
const missing = vi.fn().mockResolvedValue({ status: 404, data: {}, url: 'https://x.example.com/api/health/features' })
expect(await probeServer('https://x.example.com', missing)).toEqual({ ok: false, reason: 'notTrek' })
const down = vi.fn().mockRejectedValue(new Error('timeout'))
expect(await probeServer('x.example.com', down)).toEqual({ ok: false, reason: 'unreachable' })
})
it('keeps the most telling failure across candidates', async () => {
const get = vi.fn()
.mockResolvedValueOnce(features('https://nas.local/api/health/features', { bookingImport: false }))
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
expect(await probeServer('nas.local', get)).toEqual({ ok: false, reason: 'tooOld' })
})
it('refuses input that is not an address', async () => {
const get = vi.fn()
expect(await probeServer('', get)).toEqual({ ok: false, reason: 'invalid' })
expect(get).not.toHaveBeenCalled()
})
})
+78
View File
@@ -0,0 +1,78 @@
/**
* Turns what someone typed into the app's address field into a TREK server it
* can load, or a reason it cannot.
*/
export type ProbeFailure = 'invalid' | 'unreachable' | 'notTrek' | 'tooOld'
export type ProbeResult =
| { ok: true; url: string; reason?: undefined }
| { ok: false; reason: ProbeFailure; url?: undefined }
export interface ProbeResponse {
status: number
data: unknown
/** The URL after redirects. */
url: string
}
export type ProbeGet = (url: string) => Promise<ProbeResponse>
const FEATURES_PATH = '/api/health/features'
function normalize(raw: string): string | null {
let parsed: URL
try {
parsed = new URL(raw)
} catch {
return null
}
if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || !parsed.hostname) return null
return parsed.origin + parsed.pathname.replace(/\/+$/, '')
}
/**
* Without a scheme, https comes first and plain http second: a server on the
* home network is often reached as a bare IP and port without TLS.
*/
export function candidateUrls(input: string): string[] {
const trimmed = input.trim()
if (!trimmed) return []
const candidates = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
? [normalize(trimmed)]
: [normalize(`https://${trimmed}`), normalize(`http://${trimmed}`)]
return candidates.filter((url): url is string => url !== null)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function judge(response: ProbeResponse): ProbeResult {
if (response.status !== 200 || !isRecord(response.data)) return { ok: false, reason: 'notTrek' }
if (response.data.nativeApp === true) {
// Follow a redirect (http to https, a canonical host) so the app loads the
// address the server actually answers on.
const base = response.url.endsWith(FEATURES_PATH) ? response.url.slice(0, -FEATURES_PATH.length) : null
return { ok: true, url: (base && normalize(base)) || '' }
}
return 'bookingImport' in response.data ? { ok: false, reason: 'tooOld' } : { ok: false, reason: 'notTrek' }
}
const FAILURE_RANK: Record<ProbeFailure, number> = { invalid: 0, unreachable: 1, notTrek: 2, tooOld: 3 }
export async function probeServer(input: string, get: ProbeGet): Promise<ProbeResult> {
const candidates = candidateUrls(input)
if (!candidates.length) return { ok: false, reason: 'invalid' }
let failure: ProbeFailure = 'unreachable'
for (const candidate of candidates) {
let verdict: ProbeResult
try {
verdict = judge(await get(candidate + FEATURES_PATH))
} catch {
continue
}
if (verdict.ok) return { ok: true, url: verdict.url || candidate }
if (FAILURE_RANK[verdict.reason] > FAILURE_RANK[failure]) failure = verdict.reason
}
return { ok: false, reason: failure }
}
+36
View File
@@ -0,0 +1,36 @@
import { registerPlugin } from '@capacitor/core'
/**
* The app's own native plugin, implemented in
* ios/App/App/TrekShellPlugin.swift and
* android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java.
*
* The app is a frame around the UI the user's server delivers, so what the
* web side needs from it is small: which server that is, a way to change it,
* a system browser for the sign-ins a WebView cannot do, and on iOS the
* offline copy of the start page that stands in for the missing service
* worker.
*/
export interface TrekShellPlugin {
getServer(): Promise<{ url: string | null }>
/** Remembers the server and loads it. The current page is gone afterwards. */
setServer(options: { url: string }): Promise<void>
/** Forgets the server and returns to the address screen. */
resetServer(): Promise<void>
/** Loads the remembered server again, after it could not be reached. */
reload(): Promise<void>
/**
* Opens `url` in the system browser and resolves with the URL it was sent
* back to under `callbackScheme`. Rejects with code `CANCELLED` when the
* user closes the browser.
*/
authenticate(options: { url: string; callbackScheme: string }): Promise<{ url: string }>
/** iOS: stores the server's start page for a cold start without network. */
refreshOfflineCopy(): Promise<{ saved: boolean }>
/** iOS: loads that copy under the server's origin. False when there is none. */
openOfflineCopy(): Promise<{ opened: boolean }>
}
export const TrekShell = registerPlugin<TrekShellPlugin>('TrekShell')
export const APP_URL_SCHEME = 'com.liketrek.trek'