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
+34
View File
@@ -0,0 +1,34 @@
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n'
import type { TranslationStrings } from '@trek/shared/i18n'
// Only the native strings of every locale, not the whole UI: this page ships
// inside the app binary and shows three screens.
const bundles = import.meta.glob<TranslationStrings>('../../shared/src/i18n/*/native.ts', { eager: true, import: 'default' })
function bundleFor(code: string): TranslationStrings | undefined {
return bundles[`../../shared/src/i18n/${code}/native.ts`]
}
export function pickLocale(preferred: readonly string[]): string {
for (const lang of preferred) {
const lower = lang.toLowerCase()
if (lower === 'pt-br') return 'br'
if (lower.startsWith('zh-tw') || lower.startsWith('zh-hk') || lower.startsWith('zh-hant')) return 'zh-TW'
if (lower.startsWith('el')) return 'gr'
const prefix = lower.split('-')[0]
const match = SUPPORTED_LANGUAGE_CODES.find((code) => code.toLowerCase() === lower || code === prefix)
if (match) return match
}
return 'en'
}
export function translator(code: string) {
const strings = bundleFor(code) ?? {}
const fallback = bundleFor('en') ?? {}
return (key: string, params: Record<string, string> = {}): string => {
const template = strings[key] ?? fallback[key] ?? key
return typeof template === 'string'
? template.replace(/\{(\w+)\}/g, (whole, name: string) => params[name] ?? whole)
: key
}
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#09090b" />
<title>TREK</title>
</head>
<body>
<main id="shell" class="shell" aria-live="polite"></main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
import { CapacitorHttp } from '@capacitor/core'
import { SplashScreen } from '@capacitor/splash-screen'
import { TrekShell } from '../src/native/trekShell'
import { probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress'
import { pickLocale, translator } from './i18n'
import logoUrl from '../public/icons/icon-white.svg'
import './shell.css'
/**
* The only page the app ships itself. It asks for a server on first start and
* stands in when the remembered one cannot be reached; everything else is the
* UI that server delivers.
*/
const locale = pickLocale(navigator.languages?.length ? navigator.languages : [navigator.language])
const t = translator(locale)
document.documentElement.lang = locale
document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr'
const root = document.getElementById('shell')!
const PROBE_TIMEOUT_MS = 8000
// Native HTTP rather than fetch: the probe goes to a server of the user's
// choosing, which has no reason to answer this page's origin with CORS.
const get: ProbeGet = async (url) => {
const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS })
return { status: response.status, data: response.data, url: response.url }
}
const FAILURE_KEYS: Record<ProbeFailure, string> = {
invalid: 'native.connect.errorInvalid',
unreachable: 'native.connect.errorUnreachable',
notTrek: 'native.connect.errorNotTrek',
tooOld: 'native.connect.errorTooOld',
}
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLElementTagNameMap[K]> = {}, ...children: (Node | string)[]) {
const node = Object.assign(document.createElement(tag), props)
node.append(...children)
return node
}
function header(title: string, hint: string) {
return [
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
el('h1', { className: 'shell-title' }, title),
el('p', { className: 'shell-hint' }, hint),
]
}
function showConnect(prefill = '') {
const input = el('input', {
type: 'url',
className: 'shell-input',
placeholder: t('native.connect.placeholder'),
value: prefill,
autocapitalize: 'off',
spellcheck: false,
required: true,
})
input.setAttribute('inputmode', 'url')
input.setAttribute('autocomplete', 'url')
input.setAttribute('aria-label', t('native.connect.title'))
const error = el('p', { className: 'shell-error', role: 'alert' })
const submit = el('button', { type: 'submit', className: 'shell-button' }, t('native.connect.submit'))
const form = el('form', { className: 'shell-form', noValidate: true }, input, submit, error)
form.addEventListener('submit', async (event) => {
event.preventDefault()
error.textContent = ''
input.disabled = true
submit.disabled = true
submit.textContent = t('native.connect.checking')
const result = await probeServer(input.value, get)
if (result.ok) {
await TrekShell.setServer({ url: result.url })
return
}
error.textContent = t(FAILURE_KEYS[result.reason])
input.disabled = false
submit.disabled = false
submit.textContent = t('native.connect.submit')
input.focus()
})
root.replaceChildren(...header(t('native.connect.title'), t('native.connect.hint')), form)
}
function showUnreachable(server: string) {
const retry = el('button', { type: 'button', className: 'shell-button' }, t('native.offline.retry'))
retry.addEventListener('click', () => {
retry.disabled = true
void TrekShell.reload()
})
const change = el('button', { type: 'button', className: 'shell-link' }, t('native.changeServer'))
change.addEventListener('click', () => { void TrekShell.resetServer() })
root.replaceChildren(
...header(t('native.offline.title'), t('native.offline.hint', { server: new URL(server).host })),
el('div', { className: 'shell-form' }, retry, change),
)
}
async function start() {
const { url } = await TrekShell.getServer()
if (!url) {
showConnect()
} else {
// Only reached when loading the server failed. On iOS the stored copy of
// its start page brings the offline-capable UI up anyway.
const { opened } = await TrekShell.openOfflineCopy()
if (opened) return
showUnreachable(url)
}
await SplashScreen.hide()
}
void start()
+90
View File
@@ -0,0 +1,90 @@
/* Mirrors the app's neutral default scheme (client/src/index.css), dark first:
this page shares the launch screen's background and must not flash. */
:root {
--bg: #09090b;
--surface: #18181b;
--edge: #27272a;
--ink: #fafafa;
--muted: #a1a1aa;
--accent: #e4e4e7;
--accent-ink: #09090b;
--danger: #f87171;
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #ffffff;
--surface: #f4f4f5;
--edge: #e4e4e7;
--ink: #111827;
--muted: #6b7280;
--accent: #111827;
--accent-ink: #ffffff;
--danger: #dc2626;
color-scheme: light;
}
.shell-logo { filter: invert(1); }
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-text-size-adjust: 100%;
}
.shell {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
max-width: 420px;
margin: 0 auto;
padding: calc(env(safe-area-inset-top) + 24px) 24px calc(env(safe-area-inset-bottom) + 24px);
}
.shell-logo { width: 56px; height: 56px; margin-bottom: 8px; }
.shell-title { font-size: 1.5rem; font-weight: 600; margin: 0; }
.shell-hint { color: var(--muted); margin: 0 0 12px; line-height: 1.5; overflow-wrap: anywhere; }
.shell-form { display: flex; flex-direction: column; gap: 12px; }
.shell-input {
font: inherit;
font-size: 1rem;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid var(--edge);
background: var(--surface);
color: var(--ink);
outline: none;
}
.shell-input:focus { border-color: var(--accent); }
.shell-button {
font: inherit;
font-weight: 600;
padding: 14px 16px;
border: 0;
border-radius: 12px;
background: var(--accent);
color: var(--accent-ink);
}
.shell-button:disabled { opacity: 0.6; }
.shell-link {
font: inherit;
padding: 10px;
border: 0;
background: none;
color: var(--muted);
}
.shell-error { color: var(--danger); margin: 0; min-height: 1.5em; line-height: 1.5; }