mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
ae0d4e4520
Everything the server's UI does differently inside the iOS and Android app lives under src/native and is inert in a browser. Sign-in: SSO and the passkey button go through the system browser (signInThroughBrowser), because identity providers refuse embedded browsers and WebAuthn needs a domain association no build can have for every server. The browser end is the new /native-handoff page: it signs the user in if needed and hands the session over only after an explicit tap, so a link carrying somebody else's challenge cannot pass a session on silently. The verifier is hashed with @noble/hashes because crypto.subtle is missing on the plain-http servers many home networks use. An OIDC-only install no longer bounces to the IdP inside the WebView, and passkey management is hidden in the app. Downloads: the WebView cannot save a blob, so downloadBlob, downloadFile and openFile write to the app cache and open the share sheet. The eight places that built their own download anchor now use downloadBlob, which they should have anyway. The rest: "Change server" on the login page and in the account settings, the splash hides after the first frame, Android's back button walks history before it leaves the app, the status bar follows the scheme, and on iOS the start page is refreshed and every chunk of the build (listed in the new asset-manifest.json) is pulled into WebKit's cache so the app can start without network.
97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { App } from '@capacitor/app'
|
|
import { SplashScreen } from '@capacitor/splash-screen'
|
|
import { StatusBar, Style } from '@capacitor/status-bar'
|
|
import { startNativeApp } from './nativeApp'
|
|
import { isNativeApp, isNativeIos } from './platform'
|
|
import { keepOfflineShellFresh } from './offlineShell'
|
|
import { isEffectivelyOffline } from '../sync/networkMode'
|
|
|
|
vi.mock('@capacitor/app', () => ({ App: { addListener: vi.fn(), minimizeApp: vi.fn() } }))
|
|
vi.mock('@capacitor/splash-screen', () => ({ SplashScreen: { hide: vi.fn() } }))
|
|
vi.mock('@capacitor/status-bar', () => ({ StatusBar: { setStyle: vi.fn() }, Style: { Dark: 'DARK', Light: 'LIGHT' } }))
|
|
vi.mock('./platform', () => ({ isNativeApp: vi.fn(), isNativeIos: vi.fn() }))
|
|
vi.mock('./offlineShell', () => ({ keepOfflineShellFresh: vi.fn() }))
|
|
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn() }))
|
|
|
|
function backButtonHandler(): (event: { canGoBack: boolean }) => void {
|
|
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'backButton')
|
|
return call![1] as unknown as (event: { canGoBack: boolean }) => void
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
vi.clearAllMocks()
|
|
vi.mocked(isNativeApp).mockReturnValue(true)
|
|
vi.mocked(isNativeIos).mockReturnValue(false)
|
|
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
|
vi.mocked(keepOfflineShellFresh).mockResolvedValue()
|
|
document.documentElement.classList.remove('dark')
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
describe('startNativeApp', () => {
|
|
it('does nothing in a browser', () => {
|
|
vi.mocked(isNativeApp).mockReturnValue(false)
|
|
startNativeApp()
|
|
expect(App.addListener).not.toHaveBeenCalled()
|
|
expect(StatusBar.setStyle).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('hides the splash after the first frame', () => {
|
|
startNativeApp()
|
|
vi.runOnlyPendingTimers()
|
|
expect(SplashScreen.hide).toHaveBeenCalled()
|
|
})
|
|
|
|
it('walks back through history before it leaves the app', () => {
|
|
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {})
|
|
startNativeApp()
|
|
backButtonHandler()({ canGoBack: true })
|
|
expect(back).toHaveBeenCalled()
|
|
backButtonHandler()({ canGoBack: false })
|
|
expect(App.minimizeApp).toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps the status bar icons readable when the scheme changes', async () => {
|
|
startNativeApp()
|
|
expect(StatusBar.setStyle).toHaveBeenLastCalledWith({ style: Style.Light })
|
|
document.documentElement.classList.add('dark')
|
|
await vi.waitFor(() => expect(StatusBar.setStyle).toHaveBeenLastCalledWith({ style: Style.Dark }))
|
|
})
|
|
|
|
it('refreshes the offline start on iOS once the first screen has settled', () => {
|
|
vi.mocked(isNativeIos).mockReturnValue(true)
|
|
startNativeApp()
|
|
expect(keepOfflineShellFresh).not.toHaveBeenCalled()
|
|
vi.advanceTimersByTime(15_000)
|
|
expect(keepOfflineShellFresh).toHaveBeenCalled()
|
|
})
|
|
|
|
it('skips the refresh while offline, and never on Android', () => {
|
|
vi.mocked(isNativeIos).mockReturnValue(true)
|
|
vi.mocked(isEffectivelyOffline).mockReturnValue(true)
|
|
startNativeApp()
|
|
vi.advanceTimersByTime(15_000)
|
|
expect(keepOfflineShellFresh).not.toHaveBeenCalled()
|
|
|
|
vi.mocked(isNativeIos).mockReturnValue(false)
|
|
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
|
startNativeApp()
|
|
vi.advanceTimersByTime(15_000)
|
|
expect(keepOfflineShellFresh).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('logs a failed refresh instead of throwing', async () => {
|
|
vi.mocked(isNativeIos).mockReturnValue(true)
|
|
vi.mocked(keepOfflineShellFresh).mockRejectedValue(new Error('down'))
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
startNativeApp()
|
|
vi.advanceTimersByTime(15_000)
|
|
await vi.waitFor(() => expect(warn).toHaveBeenCalled())
|
|
})
|
|
})
|