Files
TREK/client/native-shell/main.ts
T
Maurice 037e739807 feat(native): the in-app TREK Cloud subscription flow
After picking a plan, the app now carries the whole purchase itself when the build
can take one: choose your address (checked for free as you type, against the same
endpoint the website uses), your email, and subscribe. The receipt goes to
/api/iap/redeem, and a Setting up your TREK screen waits on the new address and
loads it the moment it answers.

The purchase sits behind an IapClient seam (src/native/iap.ts): a fake drives the
whole flow in development and preview, the native client reports itself
unavailable until the store plugin and products exist, and until then the plan
picker still finishes on the web, so nothing half-wired faces a customer. New
claim and provisioning strings across all locales (en, de and the five largest
European languages translated).
2026-11-05 14:36:21 +01:00

753 lines
26 KiB
TypeScript

import { CapacitorHttp } from '@capacitor/core'
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics'
import { SplashScreen } from '@capacitor/splash-screen'
import { StatusBar, Style } from '@capacitor/status-bar'
import { TrekShell } from '../src/native/trekShell'
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress'
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL } from '../src/native/links'
import { getIapClient, type Purchase, productIdFor } from '../src/native/iap'
import { pickLocale, translator } from './i18n'
import logoUrl from '../public/icons/icon-white.svg'
import './shell.css'
/**
* The only page the app ships itself. It welcomes someone on first start, asks
* for the TREK server they open in their browser, offers TREK Cloud to someone
* without one, and stands in when a remembered server cannot be reached.
* Everything past it 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
const iap = getIapClient()
/** Whether this build can take a payment in-app; decided once at start. Until then, and in a store build without the purchase plugin, the plan picker finishes on the web. */
let iapAvailable = false
/** The subdomain suffix a cloud instance sits on, from the cloud address: `liketrek.com` → `.liketrek.com`. */
function cloudSuffix(): string {
try {
return `.${new URL(CLOUD_URL).host}`
} catch {
return '.liketrek.com'
}
}
// 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')
}
}
// ── Tiny DOM + haptics helpers ──────────────────────────────────────────────
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
props: Partial<HTMLElementTagNameMap[K]> & { dataset?: Record<string, string> } = {},
...children: (Node | string)[]
): HTMLElementTagNameMap[K] {
const { dataset, ...rest } = props
const node = document.createElement(tag)
Object.assign(node, rest)
if (dataset) for (const [k, v] of Object.entries(dataset)) node.dataset[k] = v
node.append(...children.filter((c) => c !== null && c !== undefined))
return node
}
function icon(path: string, size = 22, className?: string): SVGSVGElement {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('width', String(size))
svg.setAttribute('height', String(size))
svg.setAttribute('fill', 'none')
svg.setAttribute('stroke', 'currentColor')
svg.setAttribute('stroke-width', '2')
svg.setAttribute('stroke-linecap', 'round')
svg.setAttribute('stroke-linejoin', 'round')
if (className) svg.setAttribute('class', className)
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path')
p.setAttribute('d', path)
svg.appendChild(p)
return svg
}
const I = {
chevron: 'M9 18l6-6-6-6',
cloud: 'M17.5 19a4.5 4.5 0 0 0 .5-9 6 6 0 0 0-11.6-1.6A4 4 0 0 0 6 19z',
globe: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20',
bolt: 'M13 2 4 14h7l-1 8 9-12h-7z',
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10',
check: 'M20 6 9 17l-5-5',
}
async function tap(style: ImpactStyle = ImpactStyle.Light): Promise<void> {
try {
await Haptics.impact({ style })
} catch {
/* web / unavailable */
}
}
async function notify(type: NotificationType): Promise<void> {
try {
await Haptics.notification({ type })
} catch {
/* web / unavailable */
}
}
// ── Screen transitions ──────────────────────────────────────────────────────
let current: HTMLElement | null = null
function show(screen: HTMLElement, opts: { back?: boolean } = {}): void {
screen.classList.add('screen', 'screen-enter')
if (opts.back) screen.classList.add('screen-back')
const previous = current
if (previous) {
previous.classList.remove('screen-enter')
previous.classList.add('screen-leave')
if (opts.back) previous.classList.add('screen-back')
previous.addEventListener('animationend', () => previous.remove(), { once: true })
setTimeout(() => previous.remove(), 400)
}
root.append(screen)
current = screen
}
function externalLink(text: string, href: string): HTMLAnchorElement {
return el('a', { href, target: '_blank', rel: 'noopener noreferrer' }, text)
}
function footLinks(...extra: Node[]): HTMLElement {
return el('div', { className: 'foot-links' }, ...extra, externalLink(t('native.privacy'), PRIVACY_URL))
}
function topbar(onBack: () => void): HTMLElement {
const back = el('button', { type: 'button', className: 'back' }, icon('M15 18l-6-6 6-6', 18), t('native.back'))
back.addEventListener('click', () => {
void tap()
onBack()
})
return el('div', { className: 'topbar' }, back)
}
// ── Welcome ─────────────────────────────────────────────────────────────────
function showWelcome(opts: { back?: boolean } = {}): void {
const hero = el('img', { src: logoUrl, alt: '', className: 'hero-mark' })
const connect = el(
'button',
{ type: 'button', className: 'btn' },
el('span', { className: 'btn-row' }, el('b', {}, t('native.welcome.connect')), el('small', {}, t('native.welcome.connectSub'))),
icon(I.chevron, 20, 'chev'),
)
connect.addEventListener('click', () => {
void tap()
showConnect()
})
const cloud = el(
'button',
{ type: 'button', className: 'btn btn-ghost' },
el('span', { className: 'btn-row' }, el('b', {}, t('native.welcome.getCloud')), el('small', {}, t('native.welcome.getCloudSub'))),
icon(I.chevron, 20, 'chev'),
)
cloud.addEventListener('click', () => {
void tap()
showCloud()
})
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'))
demo.addEventListener('click', () => runDemo(demo))
show(
el(
'div',
{},
hero,
el('h1', { className: 'title' }, t('native.welcome.headline')),
el('p', { className: 'sub' }, t('native.welcome.sub')),
connect,
cloud,
demo,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL)),
),
opts,
)
}
// ── Connect (address) ───────────────────────────────────────────────────────
function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
const input = el('input', {
type: 'url',
placeholder: t('native.connect.placeholder'),
value: prefill,
autocapitalize: 'off',
spellcheck: false,
})
input.setAttribute('inputmode', 'url')
input.setAttribute('autocomplete', 'url')
// The on-screen keyboard's return key submits the address; it reads "Go", not a newline.
input.setAttribute('enterkeyhint', 'go')
input.setAttribute('aria-label', t('native.connect.title'))
const field = el('div', { className: 'field' }, input)
const label = el('span', {}, t('native.connect.submit'))
const submit = el('button', { type: 'submit', className: 'btn' }, label)
const error = el('p', { className: 'error', role: 'alert' })
const form = el('form', { className: 'form', noValidate: true }, field, submit, error)
const fail = (reason: ProbeFailure): void => {
void notify(NotificationType.Error)
field.classList.add('bad')
setTimeout(() => field.classList.remove('bad'), 350)
error.textContent = failureMessage(reason)
input.disabled = false
submit.disabled = false
label.textContent = t('native.connect.submit')
submit.replaceChildren(label)
input.focus()
}
form.addEventListener('submit', async (event) => {
event.preventDefault()
void tap(ImpactStyle.Medium)
error.textContent = ''
input.disabled = true
submit.disabled = true
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'))
const result = await probeServer(input.value, get)
if (result.ok) {
await connected(result.url)
return
}
fail(result.reason)
})
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'))
demo.addEventListener('click', () => runDemo(demo, error))
show(
el(
'div',
{},
topbar(() => showWelcome({ back: true })),
el('h1', { className: 'title-sm' }, t('native.connect.title')),
el('p', { className: 'sub' }, t('native.connect.hint')),
form,
demo,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL)),
),
opts,
)
requestAnimationFrame(() => input.focus())
}
// ── Get TREK Cloud ──────────────────────────────────────────────────────────
function feature(path: string, text: string): HTMLElement {
return el('div', { className: 'feature' }, el('span', { className: 'ic' }, icon(path, 18)), el('span', { className: 'tx' }, text))
}
function showCloud(opts: { back?: boolean } = {}): void {
const cta = el('button', { type: 'button', className: 'btn' }, t('native.cloud.cta'), icon(I.chevron, 20, 'chev'))
cta.addEventListener('click', () => {
void tap()
showPlans()
})
const have = el('button', { type: 'button', className: 'linkbtn' }, t('native.cloud.haveServer'))
have.addEventListener('click', () => {
void tap()
showConnect()
})
show(
el(
'div',
{},
topbar(() => showWelcome({ back: true })),
el('h1', { className: 'title-sm' }, t('native.cloud.title')),
el('p', { className: 'sub' }, t('native.cloud.sub')),
el('div', { className: 'features' }, feature(I.globe, t('native.cloud.f1')), feature(I.bolt, t('native.cloud.f2')), feature(I.shield, t('native.cloud.f3'))),
cta,
have,
footLinks(),
),
opts,
)
}
// ── Choose a plan (TREK Cloud) ──────────────────────────────────────────────
interface CloudPlan {
id: string
name: string
monthlyCents: number
yearlyCents: number
aiDocuments: number
storageGb: number
}
function money(cents: number): string {
return new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(cents / 100)
}
async function fetchPlans(): Promise<CloudPlan[] | null> {
try {
const res = await get(`${CLOUD_URL}/api/plans`)
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
const plans = (data as { plans?: CloudPlan[] })?.plans
return Array.isArray(plans) && plans.length ? plans : null
} catch {
return null
}
}
function subscribe(plan: CloudPlan, interval: 'month' | 'year'): void {
void tap(ImpactStyle.Medium)
// In-app purchase when this build can take one; otherwise finish on the web, so
// nothing half-wired faces a customer until the store products exist.
if (iapAvailable) {
showClaim(plan, interval)
} else {
window.open(`${CLOUD_URL}/order?plan=${encodeURIComponent(plan.id)}`, '_blank', 'noopener')
}
}
function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean): HTMLElement {
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents
const per = interval === 'year' ? t('native.plans.perYear') : t('native.plans.perMonth')
const feat = (text: string) => el('span', {}, icon(I.check, 16), text)
const sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'))
sub.addEventListener('click', () => subscribe(plan, interval))
return el(
'div',
{ className: featured ? 'plan featured' : 'plan' },
el(
'div',
{ className: 'plan-top' },
el('span', { className: 'plan-name' }, plan.name),
el('span', { className: 'plan-price' }, money(price), el('small', {}, per)),
),
el(
'div',
{ className: 'plan-feats' },
feat(t('native.plans.unlimited')),
feat(t('native.plans.storage', { n: String(plan.storageGb) })),
feat(plan.aiDocuments > 0 ? t('native.plans.aiDocs', { n: String(plan.aiDocuments) }) : t('native.plans.noAi')),
),
sub,
)
}
function showPlans(opts: { back?: boolean } = {}): void {
let interval: 'month' | 'year' = 'month'
let plans: CloudPlan[] | null = null
const content = el('div', { className: 'loading' }, el('span', { className: 'spinner' }))
const monthBtn = el('button', { type: 'button' }, t('native.plans.monthly'))
const yearBtn = el('button', { type: 'button' }, t('native.plans.yearly'), el('span', { className: 'save' }, t('native.plans.save')))
const seg = el('div', { className: 'segmented' }, monthBtn, yearBtn)
const paintSeg = () => {
monthBtn.setAttribute('aria-pressed', String(interval === 'month'))
yearBtn.setAttribute('aria-pressed', String(interval === 'year'))
}
const renderList = () => {
const list = plans
if (!list) return
content.className = 'plans'
content.replaceChildren(...list.map((p, i) => planCard(p, interval, i === list.length - 1)))
}
const choose = (next: 'month' | 'year') => {
if (interval === next) return
void tap()
interval = next
paintSeg()
renderList()
}
monthBtn.addEventListener('click', () => choose('month'))
yearBtn.addEventListener('click', () => choose('year'))
paintSeg()
const load = async () => {
plans = await fetchPlans()
if (plans) {
renderList()
return
}
content.className = ''
const retry = el('button', { type: 'button', className: 'btn btn-ghost' }, t('native.plans.retry'))
retry.addEventListener('click', () => {
void tap()
content.className = 'loading'
content.replaceChildren(el('span', { className: 'spinner' }))
void load()
})
content.replaceChildren(el('p', { className: 'error' }, t('native.plans.loadError')), retry)
}
show(
el(
'div',
{},
topbar(() => showCloud({ back: true })),
el('h1', { className: 'title-sm' }, t('native.plans.title')),
el('p', { className: 'sub' }, t('native.plans.sub')),
seg,
content,
el('p', { className: 'note' }, t('native.plans.soon')),
),
opts,
)
void load()
}
// ── Claim a TREK Cloud instance (choose address, buy, provision) ────────────
type SlugStatus = 'available' | 'taken' | 'invalid' | 'reserved' | 'error'
async function slugStatus(slug: string): Promise<SlugStatus> {
try {
const res = await get(`${CLOUD_URL}/api/slug/check?slug=${encodeURIComponent(slug)}`)
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
const status = (data as { status?: string })?.status
if (status === 'available' || status === 'taken' || status === 'invalid' || status === 'reserved') return status
return 'error'
} catch {
return 'error'
}
}
interface RedeemOk {
ok: true
instanceUrl: string
}
async function redeemPurchase(slug: string, email: string, purchase: Purchase): Promise<RedeemOk | { ok: false; code: string }> {
try {
const res = await CapacitorHttp.post({
url: `${CLOUD_URL}/api/iap/redeem`,
headers: { 'content-type': 'application/json' },
connectTimeout: PROBE_TIMEOUT_MS,
readTimeout: PROBE_TIMEOUT_MS,
data: { platform: purchase.platform, productId: purchase.productId, receipt: purchase.receipt, slug, email },
})
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
const body = (data ?? {}) as { instanceUrl?: string; code?: string }
if ((res.status === 200 || res.status === 201) && typeof body.instanceUrl === 'string') {
return { ok: true, instanceUrl: body.instanceUrl }
}
return { ok: false, code: body.code ?? 'redeem-failed' }
} catch {
return { ok: false, code: 'redeem-failed' }
}
}
function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: boolean } = {}): void {
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents
const slugInput = el('input', { type: 'text', className: 'slug-input', autocapitalize: 'off', spellcheck: false, value: '' })
slugInput.setAttribute('inputmode', 'url')
slugInput.setAttribute('autocomplete', 'off')
slugInput.setAttribute('maxlength', '40')
slugInput.setAttribute('aria-label', t('native.claim.addressLabel'))
const slugField = el('div', { className: 'field slug-field' }, slugInput, el('span', { className: 'slug-suffix' }, cloudSuffix()))
const slugNote = el('p', { className: 'hint' })
const emailInput = el('input', { type: 'email', value: '', autocapitalize: 'off', spellcheck: false })
emailInput.setAttribute('inputmode', 'email')
emailInput.setAttribute('autocomplete', 'email')
emailInput.setAttribute('enterkeyhint', 'go')
emailInput.setAttribute('aria-label', t('native.claim.emailLabel'))
const emailField = el('div', { className: 'field' }, emailInput)
const submit = el('button', { type: 'submit', className: 'btn' }, t('native.claim.subscribe', { price: money(price) }))
const error = el('p', { className: 'error', role: 'alert' })
const form = el(
'form',
{ className: 'form', noValidate: true },
slugField,
slugNote,
emailField,
el('p', { className: 'hint' }, t('native.claim.emailHint')),
submit,
error,
)
let status: SlugStatus | null = null
let timer: ReturnType<typeof setTimeout> | undefined
const noteFor: Partial<Record<SlugStatus, string>> = {
available: t('native.claim.free'),
taken: t('native.claim.taken'),
invalid: t('native.claim.invalid'),
reserved: t('native.claim.reserved'),
}
slugInput.addEventListener('input', () => {
clearTimeout(timer)
status = null
slugNote.textContent = ''
slugNote.className = 'hint'
slugField.classList.remove('bad')
const value = slugInput.value.trim().toLowerCase()
if (value.length < 3) return
timer = setTimeout(async () => {
const result = await slugStatus(value)
if (slugInput.value.trim().toLowerCase() !== value) return
status = result
slugNote.textContent = noteFor[result] ?? ''
slugNote.className = `hint ${result === 'available' ? 'ok' : result === 'error' ? '' : 'bad'}`
}, 350)
})
const shake = (field: HTMLElement): void => {
void notify(NotificationType.Error)
field.classList.add('bad')
setTimeout(() => field.classList.remove('bad'), 350)
}
form.addEventListener('submit', async (event) => {
event.preventDefault()
error.textContent = ''
const slug = slugInput.value.trim().toLowerCase()
const email = emailInput.value.trim()
if (slug.length < 3 || status === 'taken' || status === 'invalid' || status === 'reserved') {
shake(slugField)
slugInput.focus()
return
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
shake(emailField)
emailInput.focus()
return
}
void tap(ImpactStyle.Medium)
const lock = (on: boolean) => {
slugInput.disabled = on
emailInput.disabled = on
submit.disabled = on
}
lock(true)
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'))
const restore = () => {
lock(false)
submit.replaceChildren(t('native.claim.subscribe', { price: money(price) }))
}
const outcome = await iap.purchase(productIdFor(plan.id, interval))
if (outcome.kind === 'cancelled') {
restore()
error.textContent = t('native.claim.cancelled')
return
}
if (outcome.kind === 'failed') {
void notify(NotificationType.Error)
restore()
error.textContent = t('native.claim.failed')
return
}
const redeemed = await redeemPurchase(slug, email, outcome.purchase)
if (redeemed.ok) {
showProvisioning(redeemed.instanceUrl)
return
}
void notify(NotificationType.Error)
restore()
if (redeemed.code === 'slug-taken') {
shake(slugField)
slugNote.textContent = t('native.claim.taken')
slugNote.className = 'hint bad'
} else {
error.textContent = t('native.claim.redeemFailed')
}
})
show(
el(
'div',
{},
topbar(() => showPlans({ back: true })),
el('h1', { className: 'title-sm' }, t('native.claim.title')),
el('p', { className: 'sub' }, t('native.claim.sub')),
form,
),
opts,
)
requestAnimationFrame(() => slugInput.focus())
}
/** The build runs on the server; the app waits on the new address and loads it the moment it answers. */
function showProvisioning(instanceUrl: string): void {
const host = (() => {
try {
return new URL(instanceUrl).host
} catch {
return instanceUrl
}
})()
const spinner = el('div', { className: 'provision-mark' }, el('span', { className: 'spinner' }))
show(
el(
'div',
{ className: 'screen-center' },
spinner,
el('h1', { className: 'title-sm' }, t('native.provisioning.title')),
el('p', { className: 'sub' }, t('native.provisioning.sub', { host })),
),
)
const deadline = Date.now() + 180_000
const poll = async (): Promise<void> => {
const result = await probeServer(instanceUrl, get)
if (result.ok) {
await connected(result.url)
return
}
if (Date.now() > deadline) {
showProvisioningSlow(instanceUrl)
return
}
setTimeout(() => void poll(), 4000)
}
setTimeout(() => void poll(), 3000)
}
function showProvisioningSlow(instanceUrl: string): void {
void notify(NotificationType.Warning)
const retry = el('button', { type: 'button', className: 'btn' }, t('native.provisioning.retry'))
retry.addEventListener('click', () => {
void tap()
showProvisioning(instanceUrl)
})
show(
el(
'div',
{ className: 'screen-center' },
el('h1', { className: 'title-sm' }, t('native.provisioning.failedTitle')),
el('p', { className: 'sub' }, t('native.provisioning.failedSub')),
retry,
),
)
}
// ── Shared actions ──────────────────────────────────────────────────────────
async function runDemo(button: HTMLButtonElement, error?: HTMLElement): Promise<void> {
void tap()
if (error) error.textContent = ''
button.disabled = true
const result = await probeDemo(get)
if (result.ok) {
await connected(result.url)
return
}
button.disabled = false
if (error) {
error.textContent = failureMessage(result.reason)
} else {
void notify(NotificationType.Error)
}
}
/** The tick, then the server takes over. A beat so the success registers. */
async function connected(url: string): Promise<void> {
void notify(NotificationType.Success)
const host = (() => {
try {
return new URL(url).host
} catch {
return url
}
})()
const tick = el('div', { className: 'tick' })
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('fill', 'none')
svg.setAttribute('stroke', 'currentColor')
svg.setAttribute('stroke-width', '2.5')
svg.setAttribute('stroke-linecap', 'round')
svg.setAttribute('stroke-linejoin', 'round')
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path')
p.setAttribute('d', I.check)
svg.appendChild(p)
tick.appendChild(svg)
show(el('div', { className: 'success' }, tick, el('h1', { className: 'title-sm' }, t('native.connecting', { host }))))
await new Promise((r) => setTimeout(r, 700))
await TrekShell.setServer({ url })
}
function showUnreachable(server: string): void {
const retry = el('button', { type: 'button', className: 'btn' }, t('native.offline.retry'))
retry.addEventListener('click', () => {
void tap()
retry.disabled = true
void TrekShell.reload()
})
const change = el('button', { type: 'button', className: 'linkbtn' }, t('native.changeServer'))
change.addEventListener('click', () => {
void tap()
void TrekShell.resetServer()
})
show(
el(
'div',
{},
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
el('h1', { className: 'title-sm' }, t('native.offline.title')),
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
retry,
change,
footLinks(),
),
)
}
// ── Start ───────────────────────────────────────────────────────────────────
/**
* The address screen is always on the near-black surface, so the status bar must
* draw its clock and icons in light. The server's UI sets its own style once it
* loads (native/nativeApp.ts); this is only for the screen the app ships. Fails
* quietly where there is no status bar (web, the simulator without the plugin).
*/
async function paintStatusBar(): Promise<void> {
try {
await StatusBar.setStyle({ style: Style.Dark })
} catch {
/* web / unavailable: no native status bar to style */
}
}
async function start(): Promise<void> {
void paintStatusBar()
iap.isAvailable().then((ok) => {
iapAvailable = ok
})
const { url } = await TrekShell.getServer()
if (!url) {
showWelcome()
} 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()