Files
TREK/client/native-shell/main.ts
T
Maurice 8b6a65023f Merge dev into feat/native-apps
Brings the native apps branch up to dev (NestJS-only server, MikroORM
repositories, tracked sessions, per-domain locales, the lint ratchets) and
ports its new code onto that architecture:

- server: /api/auth/native moves onto injected services. The session is
  minted by the auth domain's new LoginSessionService (login bookkeeping and
  SessionsService.issue in one transaction), the one-time codes live behind a
  NativeHandoffStore port with a capped in-memory implementation that drops
  expired codes on write instead of running a timer, both routes declare
  response contracts, and the exchange answers { success: true }. Unit and
  e2e suites run on createTestModule and the ORM factories.
- shared: native strings for az, et, sk and th, native wired into every
  locale, the address placeholder as a full URL.
- client: the account and export changes go into dev's shared hooks
  (useAccountSettings, useTripExport), the native server card is a
  desktop/phone pair on useNativeServer, the hand-off call moves out of the
  page hook into native/, best-effort native failures go through
  reportError instead of the console, and the address screen is known to
  knip and lint:i18n-keys.
- package-lock.json regenerated on Linux.
2026-10-25 17:04:15 +01:00

126 lines
4.3 KiB
TypeScript

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 }
}
function failureMessage(reason: ProbeFailure): string {
switch (reason) {
case 'invalid':
return t('native.connect.errorInvalid')
case 'unreachable':
return t('native.connect.errorUnreachable')
case 'notTrek':
return t('native.connect.errorNotTrek')
case 'tooOld':
return t('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 = failureMessage(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()