mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user