Compare commits

...

16 Commits

Author SHA1 Message Date
Maurice 5e7d778740 fix(assistant): the step after the ask for the answer may only write text, so Claude Sonnet 5.5 can no longer offer its follow-ups a second time instead (#3016) 2026-11-08 21:16:09 +01:00
Maurice 3be87343b8 fix: the gate texts formatted as the locales are, gate detection as a table of rules, and no non-null assertions in its tests 2026-11-08 20:58:49 +01:00
Maurice 517efabbca feat(native): the address screen recognises a sign-in proxy in front of TREK and signs in to it inside the app, so its session works as in a browser 2026-11-08 20:44:26 +01:00
Maurice 1866dca818 test(dashboard): the pair-change test stubs fetch with its own mock instead of a promise-returning mockImplementation 2026-11-08 20:10:22 +01:00
Maurice 1400b5d308 fix: the iOS tile trim handles its own failure instead of an empty catch, and the weight test's slow row saves without an async prop 2026-11-08 19:57:43 +01:00
Maurice b529cf4e57 fix: the checks the layout and weight fixes broke: a quote in a test name, state set inside effects, the misused-promise baseline, and tests that render the converter before settings load 2026-11-08 19:44:50 +01:00
Maurice 6cc3d235b1 fix(offline): on an iPhone or iPad the tile prefetch keeps to 2,048 tiles and a fuller cache is trimmed once a day, so the home screen app no longer starts on a black screen (#3008) 2026-11-08 19:33:13 +01:00
Maurice a5b1156612 fix(dashboard): the hero, the atlas cards and the collections widget hold their size while they load, and the currency widget shows only the saved pair and its own rates (#3009) 2026-11-08 19:26:18 +01:00
Maurice c6ffd48e26 fix(packing): the weight field keeps what is typed and saves it once, on blur or Enter, instead of a fragment per keystroke on a slow connection (#3005) 2026-11-08 19:19:04 +01:00
Maurice afb6eae4c8 fix(native): the handoff's passkey sign-in lives in native/browserSignIn, so the page hook reaches no API on its own 2026-11-08 18:33:00 +01:00
Maurice cd1f39377d test(native): the handoff page's fixture knows the passkey step, and the hook test keeps the real API client around its passkey mocks 2026-11-08 18:10:55 +01:00
Maurice 3a99e8576d fix(android): the sign-in browser opens from the UI thread, falls back to the plain browser where no Custom Tab comes up, and says so when none opens 2026-11-08 17:54:43 +01:00
Maurice 36c8c5bc48 fix(native): a passkey sign-in from the app asks for the passkey straight away in the browser and returns to the app, instead of showing the whole login a second time 2026-11-08 17:50:50 +01:00
Maurice 4ba7db90e6 fix(native): SSO in the system browser hands back to the app even when the provider finishes in another tab, and the handoff button's label shows 2026-11-08 17:43:28 +01:00
Maurice ab6cd7009e fix(login): the language picker's safe-area offset as classes, within the file's size baseline 2026-11-08 17:14:25 +01:00
Maurice b16ac683cf fix(native): Android's back gesture steps back through the first screens, the login's language picker clears the status bar, and a slow trip open shows its splash sooner 2026-11-08 16:51:28 +01:00
81 changed files with 1715 additions and 112 deletions
@@ -36,7 +36,7 @@ public class MainActivity extends BridgeActivity {
// scroller natively. It scales the whole view, top bar and bottom dock included.
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
// Set before any WebView callback can run: they are posted to this thread.
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen, TrekServerStore.getSignInHosts(this)));
TrekSharedFiles.receive(this, getIntent());
// Android 15's widget picker draws the widgets live from a sample trip.
TrekWidgetUpdater.publishPreviews(this);
@@ -10,6 +10,10 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import org.json.JSONException;
import org.json.JSONObject;
@@ -18,6 +22,7 @@ final class TrekServerStore {
private static final String PREFS = "trek_shell";
private static final String KEY_URL = "server_url";
private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts";
private static final String KEY_SHELL_ONCE = "shell_once";
private static final String KEY_RESTART = "restart";
private static final String CONFIG_DIR = "trek-server-config";
@@ -28,16 +33,39 @@ final class TrekServerStore {
return prefs(context).getString(KEY_URL, null);
}
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
static void setUrl(Context context, String url) {
setServer(context, url, null);
}
static void setServer(Context context, String url, Set<String> signInHosts) {
SharedPreferences.Editor editor = prefs(context).edit();
if (url == null) {
editor.remove(KEY_URL);
} else {
editor.putString(KEY_URL, url);
}
if (signInHosts == null || signInHosts.isEmpty()) {
editor.remove(KEY_SIGN_IN_HOSTS);
} else {
Set<String> lower = new HashSet<>();
for (String host : signInHosts) lower.add(host.toLowerCase(Locale.ROOT));
editor.putStringSet(KEY_SIGN_IN_HOSTS, lower);
}
editor.commit();
}
/**
* The hosts of a sign-in page that stands in front of the server (a proxy
* such as Cloudflare Access or Pangolin), as the address screen found them.
* Only these may load inside the web view besides the server itself, so
* the proxy's session lands where the app can use it (TrekWebViewClient).
*/
static Set<String> getSignInHosts(Context context) {
Set<String> hosts = prefs(context).getStringSet(KEY_SIGN_IN_HOSTS, null);
return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts));
}
/** Start the next launch on the bundled address screen, even with a server remembered. */
static void showShellOnce(Context context) {
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
@@ -6,8 +6,10 @@ import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationManagerCompat;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.PermissionState;
import com.getcapacitor.Plugin;
@@ -17,6 +19,8 @@ import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission;
import com.getcapacitor.annotation.PermissionCallback;
import com.google.firebase.messaging.FirebaseMessaging;
import java.util.HashSet;
import java.util.Set;
import org.json.JSONObject;
/** The native half of client/src/native/trekShell.ts. */
@@ -29,6 +33,8 @@ import org.json.JSONObject;
)
public class TrekShellPlugin extends Plugin {
private static final String TAG = "TrekShell";
/** How long to wait after returning from the browser for the redirect to land. */
private static final long CANCEL_GRACE_MS = 600;
@@ -272,11 +278,23 @@ public class TrekShellPlugin extends Plugin {
call.reject("A http(s) URL is required");
return;
}
TrekServerStore.setUrl(getContext(), url);
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
call.resolve();
restart();
}
/** The sign-in hosts the address screen passes along, plain host names only. */
private static Set<String> signInHostsOf(PluginCall call) {
Set<String> hosts = new HashSet<>();
JSArray list = call.getArray("signInHosts");
if (list == null) return hosts;
for (int i = 0; i < list.length(); i++) {
String host = list.optString(i, "");
if (host.matches("[A-Za-z0-9.-]+(:[0-9]+)?")) hosts.add(host);
}
return hosts;
}
@PluginMethod
public void resetServer(PluginCall call) {
TrekServerStore.setUrl(getContext(), null);
@@ -315,7 +333,32 @@ public class TrekShellPlugin extends Plugin {
// A Custom Tab shares the browser's cookies, so a running SSO session at
// the identity provider carries over. The redirect back to the app's
// scheme arrives through the intent-filter in AndroidManifest.xml.
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url));
Uri target = Uri.parse(url);
getActivity().runOnUiThread(() -> openSignIn(target));
}
/**
* Opens the sign-in in a Custom Tab, or in the browser itself where no
* Custom Tab can come up. Launched on the UI thread: some devices dropped a
* launch from the bridge's thread without a word, and the sign-in then
* looked cancelled. A browser that cannot open at all is reported as such.
*/
private void openSignIn(Uri target) {
try {
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), target);
return;
} catch (RuntimeException tabFailed) {
Log.w(TAG, "No Custom Tab for the sign-in, trying the browser", tabFailed);
}
try {
getActivity().startActivity(new Intent(Intent.ACTION_VIEW, target).addCategory(Intent.CATEGORY_BROWSABLE));
} catch (RuntimeException browserFailed) {
Log.w(TAG, "No browser for the sign-in", browserFailed);
PluginCall call = pendingAuthCall();
if (call == null) return;
call.reject("No browser could open the sign-in", "NO_BROWSER");
finishAuth(call);
}
}
@Override
@@ -1,11 +1,14 @@
package com.liketrek.trek;
import android.net.Uri;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient;
import java.util.Locale;
import java.util.Set;
/**
* Used while the WebView shows the user's server. When that server cannot be
@@ -18,10 +21,35 @@ import com.getcapacitor.BridgeWebViewClient;
class TrekWebViewClient extends BridgeWebViewClient {
private final Runnable onUnreachable;
private final Set<String> signInHosts;
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) {
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
super(bridge);
this.onUnreachable = onUnreachable;
this.signInHosts = signInHosts;
}
/**
* The sign-in page of a proxy in front of the server loads here rather than
* in the browser, so its session cookie is the web view's and every request
* of the app carries it, as in a browser. Only the hosts the address screen
* saw that proxy send it to (TrekServerStore.getSignInHosts), and only as a
* page: Capacitor injects its bridge into the server's origin alone, so the
* proxy's page gets no access to the app. Everything else goes on to
* Capacitor, which opens a foreign page in the browser.
*/
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false;
return super.shouldOverrideUrlLoading(view, request);
}
private boolean isSignInHost(Uri url) {
String scheme = url.getScheme();
String host = url.getHost();
if (host == null || !("https".equals(scheme) || "http".equals(scheme))) return false;
String key = host.toLowerCase(Locale.ROOT);
return signInHosts.contains(key) || (url.getPort() != -1 && signInHosts.contains(key + ":" + url.getPort()));
}
/** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */
@@ -43,6 +43,7 @@ class TrekBridgeViewController: CAPBridgeViewController {
navigationProxy = proxy
webView.navigationDelegate = proxy
}
guardBridgeMessages()
bridge?.registerPluginInstance(TrekShellPlugin())
// WebKit's own swipe back slides away a screenshot of the whole page,
// closes no sheet and takes every drag near the edge from the UI. The UI
@@ -51,6 +52,22 @@ class TrekBridgeViewController: CAPBridgeViewController {
webView?.allowsBackForwardNavigationGestures = false
}
/// Kept alive here: the content controller holds it, but only for as long as it is registered.
private var messageGuard: TrekBridgeMessageGuard?
/// Capacitor answers bridge messages from any page in the web view. With a
/// proxy's sign-in page loading there, its messages are dropped before they
/// reach the plugins.
private func guardBridgeMessages() {
guard !TrekServerStore.signInHosts.isEmpty,
let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
let controller = webView?.configuration.userContentController else { return }
let guarded = TrekBridgeMessageGuard(inner: handler)
controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name)
controller.add(guarded, name: TrekBridgeMessageGuard.name)
messageGuard = guarded
}
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
/// in the main menu, which sends their action up the responder chain from
/// the web view, and this controller is the first stop that answers it.
@@ -137,14 +154,43 @@ enum TrekKeyCommands {
enum TrekServerStore {
private static let key = "TrekServerURL"
private static let signInHostsKey = "TrekServerSignInHosts"
/// Setting the server drops the sign-in hosts of the one before it.
static var url: String? {
get { UserDefaults.standard.string(forKey: key) }
set {
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) }
UserDefaults.standard.removeObject(forKey: signInHostsKey)
try? FileManager.default.removeItem(at: offlineCopyURL)
}
}
/// The hosts of a sign-in page in front of the server (a proxy such as
/// Cloudflare Access or Pangolin), as the address screen found them. Only
/// these load in the web view besides the server, so the proxy's session
/// cookie is the web view's (TrekNavigationProxy), and none of them may
/// talk to the app (TrekBridgeMessageGuard).
static var signInHosts: Set<String> {
get { Set((UserDefaults.standard.stringArray(forKey: signInHostsKey) ?? []).map { $0.lowercased() }) }
set {
if newValue.isEmpty {
UserDefaults.standard.removeObject(forKey: signInHostsKey)
} else {
UserDefaults.standard.set(Array(newValue).sorted(), forKey: signInHostsKey)
}
}
}
/// Whether `url` is one of the sign-in hosts, matched on host or host:port.
static func isSignInHost(_ url: URL?) -> Bool {
guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false }
let hosts = signInHosts
if hosts.contains(host) { return true }
if let port = url.port { return hosts.contains("\(host):\(port)") }
return false
}
static var offlineCopyURL: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return support.appendingPathComponent("offline-start-page.html")
@@ -391,6 +437,23 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
(inner as AnyObject).responds(to: aSelector) ? inner : nil
}
/// The sign-in page of a proxy in front of the server loads here instead of
/// in Safari, so its session is the web view's and every request carries it.
/// Only the hosts the address screen saw (TrekServerStore.signInHosts), and
/// only in the main frame; everything else is Capacitor's decision as before.
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.targetFrame?.isMainFrame == true, TrekServerStore.isSignInHost(navigationAction.request.url) {
decisionHandler(.allow)
return
}
let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:")
guard (inner as AnyObject).responds(to: selector) else {
decisionHandler(.allow)
return
}
inner.webView?(webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return }
inner.webView?(webView, didFail: navigation, withError: error)
@@ -401,3 +464,26 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
}
}
/// Stands in front of Capacitor's bridge message handler and drops what a
/// proxy's sign-in page posts: that page loads in the web view (see
/// TrekNavigationProxy), and Capacitor itself would hand its messages to the
/// plugins whatever page they come from.
final class TrekBridgeMessageGuard: NSObject, WKScriptMessageHandler {
static let name = "bridge"
private let inner: WKScriptMessageHandler
init(inner: WKScriptMessageHandler) {
self.inner = inner
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
let origin = message.frameInfo.securityOrigin
var components = URLComponents()
components.scheme = origin.protocol
components.host = origin.host
if origin.port != 0 { components.port = origin.port }
if TrekServerStore.isSignInHost(components.url) { return }
inner.userContentController(userContentController, didReceive: message)
}
}
+3
View File
@@ -324,6 +324,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
return
}
TrekServerStore.url = raw
// After the URL: setting it clears the hosts of the server before.
let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil }
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
call.resolve()
restartBridge()
}
+81 -8
View File
@@ -1,3 +1,4 @@
import { App } from '@capacitor/app';
import { Capacitor, CapacitorHttp } from '@capacitor/core';
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
import { SplashScreen } from '@capacitor/splash-screen';
@@ -5,9 +6,17 @@ import { StatusBar, Style } from '@capacitor/status-bar';
import logoUrl from '../public/icons/icon-white.svg';
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
import { AUTH_GATE_NAMES, canSignInInApp, type AuthGate } from '../src/native/authGate';
import { opensSoon, refundedBeforeOpening } from '../src/native/cloudOrders';
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
import { CLOUD_TERMS_URL, CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
import {
AUTH_PROXY_DOCS_URL,
CLOUD_TERMS_URL,
CLOUD_URL,
PRIVACY_URL,
SERVER_DOCS_URL,
STORE_TERMS_URL,
} from '../src/native/links';
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
import { TrekShell } from '../src/native/trekShell';
import { directionOf, pickLocale, translator } from './i18n';
@@ -51,7 +60,7 @@ function cloudSuffix(): string {
// 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 };
return { status: response.status, data: response.data, url: response.url, headers: response.headers };
};
function failureMessage(reason: ProbeFailure): string {
@@ -62,6 +71,8 @@ function failureMessage(reason: ProbeFailure): string {
return t('native.connect.errorUnreachable');
case 'notTrek':
return t('native.connect.errorNotTrek');
case 'authGate':
return t('native.gate.titleUnknown');
case 'tooOld':
return t('native.connect.errorTooOld');
}
@@ -251,13 +262,19 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
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 gatePanel = el('div', { className: 'gate', role: 'alert', hidden: true });
const form = el('form', { className: 'form', noValidate: true }, field, submit, error, gatePanel);
const fail = (reason: ProbeFailure): void => {
const fail = (reason: ProbeFailure, gate?: AuthGate, server?: string): void => {
void notify(NotificationType.Error);
field.classList.add('bad');
setTimeout(() => field.classList.remove('bad'), 350);
error.textContent = failureMessage(reason);
if (reason === 'authGate' && gate && server) {
error.textContent = '';
fillGatePanel(gatePanel, gate, server);
} else {
error.textContent = failureMessage(reason);
}
input.disabled = false;
submit.disabled = false;
label.textContent = t('native.connect.submit');
@@ -269,6 +286,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
event.preventDefault();
void tap(ImpactStyle.Medium);
error.textContent = '';
gatePanel.hidden = true;
gatePanel.replaceChildren();
input.disabled = true;
submit.disabled = true;
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'));
@@ -277,7 +296,7 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
await connected(result.url);
return;
}
fail(result.reason);
fail(result.reason, result.gate, result.url);
});
show(
@@ -298,6 +317,43 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
requestAnimationFrame(() => input.focus());
}
/**
* A sign-in page of the user's own stands in front of the server (authGate.ts):
* say which one, offer to sign in to it inside the app, which keeps its session
* as a browser would, and how to let the app through instead.
*/
function fillGatePanel(panel: HTMLElement, gate: AuthGate, server: string): void {
const name = gate.kind === 'unknown' || gate.kind === 'basic' ? null : AUTH_GATE_NAMES[gate.kind];
const title =
gate.kind === 'basic'
? t('native.gate.titleBasic')
: name
? t('native.gate.title', { name })
: t('native.gate.titleUnknown');
const children: Node[] = [el('p', { className: 'gate-title' }, title)];
if (canSignInInApp(gate)) {
const signIn = el(
'button',
{ type: 'button', className: 'btn' },
name ? t('native.gate.signIn', { name }) : t('native.gate.signInGeneric')
);
signIn.addEventListener('click', () => {
void tap(ImpactStyle.Medium);
void connected(server, gate.loginHost ? [gate.loginHost] : []);
});
children.push(
el('p', { className: 'gate-text' }, t('native.gate.hint')),
signIn,
el('p', { className: 'gate-note' }, t('native.gate.googleNote'))
);
} else {
children.push(el('p', { className: 'gate-text' }, t('native.gate.basicHint')));
}
children.push(externalLink(t('native.gate.help'), AUTH_PROXY_DOCS_URL));
panel.replaceChildren(...children);
panel.hidden = false;
}
// ── Get TREK Cloud ──────────────────────────────────────────────────────────
function feature(path: string, text: string): HTMLElement {
return el(
@@ -942,7 +998,7 @@ async function runDemo(button: HTMLButtonElement, error?: HTMLElement): Promise<
}
/** The tick, then the server takes over. A beat so the success registers. */
async function connected(url: string): Promise<void> {
async function connected(url: string, signInHosts: string[] = []): Promise<void> {
void notify(NotificationType.Success);
const host = (() => {
try {
@@ -967,7 +1023,7 @@ async function connected(url: string): Promise<void> {
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 });
await TrekShell.setServer(signInHosts.length ? { url, signInHosts } : { url });
}
function showUnreachable(server: string): void {
@@ -1013,8 +1069,25 @@ async function paintStatusBar(): Promise<void> {
}
}
/**
* Android's back gesture steps back through the first screens as their own
* back button does, and only leaves the app from a screen that has none.
*/
function followBackGesture(): void {
try {
void App.addListener('backButton', () => {
const back = current?.querySelector<HTMLButtonElement>('.topbar .back');
if (back) back.click();
else void App.minimizeApp();
});
} catch {
/* web / unavailable: no system back gesture */
}
}
async function start(): Promise<void> {
void paintStatusBar();
followBackGesture();
let url: string | null;
try {
({ url } = await TrekShell.getServer());
+37
View File
@@ -753,6 +753,43 @@ a.linkbtn {
transition: opacity 0.2s;
}
/* A sign-in proxy in front of the server: which one, sign in to it here, or let the app through. */
.gate {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 18px;
border-radius: 22px;
background: var(--surface);
border: 1px solid var(--edge);
text-align: start;
}
.gate[hidden] {
display: none;
}
.gate-title {
margin: 0;
font-weight: 600;
color: var(--ink);
line-height: 1.4;
}
.gate-text {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.5;
}
.gate-note {
margin: 0;
color: var(--faint);
font-size: 0.82rem;
line-height: 1.45;
}
.gate a {
color: var(--ink);
font-size: 0.9rem;
}
/* Spinner inside the connect button while probing. */
.spinner {
width: 18px;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 375,
"@typescript-eslint/no-misused-promises": 845,
"@typescript-eslint/no-misused-promises": 843,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
@@ -77,10 +77,24 @@ describe('CollectionsWidget', () => {
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
});
it('FE-COMP-COLWIDGET-008: nothing renders while the fetch is still in flight', async () => {
render(<CollectionsWidget onOpen={() => {}} />);
it('FE-COMP-COLWIDGET-008: only a placeholder shows while the fetch is still in flight', async () => {
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
expect(screen.queryByText('No saved places yet')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByText('No saved places yet')).toBeInTheDocument());
// Nothing remembered yet: the empty hint's shape, hidden from assistive tech, holds the place.
expect(container.querySelector('.col-empty.trek-skeleton')).toHaveAttribute('aria-hidden', 'true');
await waitFor(() => expect(container.querySelector('.trek-skeleton')).toBeNull());
expect(screen.getByText('No saved places yet')).toBeInTheDocument();
});
it('FE-COMP-COLWIDGET-009: #3009 holds the badges it showed last time while the lists load, then remembers the new count', async () => {
localStorage.setItem('trek_dash_collections_shown', '3');
collections([1, 2].map(id => list({ id, name: `List ${id}` })));
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(3);
expect(await screen.findByText('List 1')).toBeInTheDocument();
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(0);
await waitFor(() => expect(localStorage.getItem('trek_dash_collections_shown')).toBe('2'));
localStorage.clear();
});
});
@@ -1,9 +1,10 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { Bookmark, ArrowRight, MapPin } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useTranslation } from '../../i18n'
import { entityGradient } from '../../utils/gradients'
import { useCollectionLists } from './useCollectionLists'
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory'
/**
* Dashboard sidebar widget — a glassy `.tool` card that surfaces the user's
@@ -16,6 +17,10 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
const { t } = useTranslation()
const navigate = useNavigate()
const { lists, loading } = useCollectionLists()
const [expected] = useState(readShownCollections)
useEffect(() => {
if (!loading) rememberShownCollections(Math.min(lists.length, 6))
}, [loading, lists.length])
return (
<div className="tool">
@@ -25,7 +30,15 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
<ArrowRight size={14} />
</button>
</div>
{loading ? null : lists.length === 0 ? (
{loading ? (
expected ? (
<div className="col-badges" aria-hidden>
{Array.from({ length: expected }, (_, i) => <div key={i} className="col-badge col-badge-pending trek-skeleton" />)}
</div>
) : (
<div className="col-empty trek-skeleton" aria-hidden>{t('collections.widget.empty')}</div>
)
) : lists.length === 0 ? (
<div className="col-empty">{t('collections.widget.empty')}</div>
) : (
<div className="col-badges">
@@ -0,0 +1,21 @@
// FE-COMP-COLMEMORY-001 to -002: how many lists the collections widget showed last time.
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory';
afterEach(() => localStorage.clear());
describe('collectionsWidgetMemory', () => {
it('FE-COMP-COLMEMORY-001: remembers the count, kept within the six badges the widget shows', () => {
expect(readShownCollections()).toBeNull();
rememberShownCollections(4);
expect(readShownCollections()).toBe(4);
rememberShownCollections(9);
expect(readShownCollections()).toBe(6);
});
it('FE-COMP-COLMEMORY-002: ignores a value that is not a count', () => {
localStorage.setItem('trek_dash_collections_shown', 'many');
expect(readShownCollections()).toBeNull();
localStorage.setItem('trek_dash_collections_shown', '42');
expect(readShownCollections()).toBeNull();
});
});
@@ -0,0 +1,26 @@
const KEY = 'trek_dash_collections_shown';
/**
* How many lists the dashboard's collections widget showed last time. While the
* lists load, the widget stands in with that many placeholder badges, so it
* opens at the height it will have and the widgets under it stay put (#3009).
* Null when nothing is remembered yet, or storage is unavailable.
*/
export function readShownCollections(): number | null {
try {
const raw = localStorage.getItem(KEY);
if (raw == null) return null;
const n = Number.parseInt(raw, 10);
return Number.isInteger(n) && n >= 0 && n <= 6 ? n : null;
} catch {
return null;
}
}
export function rememberShownCollections(count: number): void {
try {
localStorage.setItem(KEY, String(Math.max(0, Math.min(6, count))));
} catch {
/* storage blocked: the widget just opens at its empty height next time */
}
}
@@ -1,4 +1,4 @@
// FE-COMP-FXHOOK-001 to -008: the currency converter both dashboards share.
// FE-COMP-FXHOOK-001 to -010: the currency converter both dashboards share.
import { act, renderHook, waitFor } from '@testing-library/react';
import { useSettingsStore } from '../../store/settingsStore';
@@ -13,7 +13,7 @@ const RATES = [
const updateSetting = vi.fn<(key: string, value: unknown) => Promise<void>>();
let fetchMock: ReturnType<typeof vi.fn>;
function seedSettings(over: Record<string, unknown> = {}, isLoaded = false) {
function seedSettings(over: Record<string, unknown> = {}, isLoaded = true) {
useSettingsStore.setState((s) => ({
isLoaded,
updateSetting: updateSetting as unknown as typeof s.updateSetting,
@@ -133,4 +133,33 @@ describe('useCurrencyConverter', () => {
});
expect(result.current.rate).toBe(1.3);
});
it('FE-COMP-FXHOOK-009: #3009 waits for the saved pair before it fetches or shows a pair', async () => {
seedSettings({}, false);
const { result } = renderHook(() => useCurrencyConverter());
expect(result.current.pairPending).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
expect(result.current.pairPending).toBe(false);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(fetchMock.mock.calls[0][0]).toContain('base=JPY');
});
it('FE-COMP-FXHOOK-010: #3009 never prices a new pair with the previous base and says it is still loading', async () => {
fetchMock = vi.fn((url: string) =>
respond(url.endsWith('base=JPY') ? [{ quote: 'EUR', rate: 0.0056 }] : [{ quote: 'USD', rate: 1.1 }])
);
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useCurrencyConverter());
await waitFor(() => expect(result.current.rate).toBe(1.1));
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
// EUR's map holds EUR at 1, which used to show as "1 JPY = 1.0000 EUR".
expect(result.current.rate).toBeNull();
expect(result.current.ratePending).toBe(true);
await waitFor(() => expect(result.current.rate).toBe(0.0056));
expect(result.current.ratePending).toBe(false);
});
});
@@ -23,10 +23,18 @@ export function useCurrencyConverter() {
updateSetting('dashboard_fx_to', v).catch(() => {});
};
const [amount, setAmount] = useState('100');
const [rates, setRates] = useState<Record<string, number> | null>(null);
// The rates carry the base they were fetched for: the pair can change before
// the new base's rates arrive, and the old map priced the new pair wrongly
// (EUR's self-rate of 1 showed as "1 JPY = 1.0000 EUR", #3009).
const [rates, setRates] = useState<{ base: string; map: Record<string, number> } | null>(null);
// The base whose rates could not be had, so a failure belongs to its pair only.
const [failedBase, setFailedBase] = useState<string | null>(null);
const inFlight = useRef<AbortController | null>(null);
const fetchRates = useCallback(() => {
// Not before the user's pair is known: the defaults' rates were fetched and
// shown first, then replaced by the saved pair (#3009).
if (!isLoaded) return;
inFlight.current?.abort();
const controller = new AbortController();
inFlight.current = controller;
@@ -37,18 +45,22 @@ export function useCurrencyConverter() {
if (signal.aborted) return;
if (!Array.isArray(d)) {
setRates(null);
setFailedBase(from);
return;
}
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
const map: Record<string, number> = { [from]: 1 };
for (const r of d) map[r.quote] = r.rate;
setRates(map);
setFailedBase(null);
setRates({ base: from, map });
})
.catch(() => {
// An abort is not a failure: it means nobody is waiting for the answer.
if (!signal.aborted) setRates(null);
if (signal.aborted) return;
setRates(null);
setFailedBase(from);
});
}, [from]);
}, [from, isLoaded]);
useEffect(() => {
fetchRates();
@@ -78,13 +90,32 @@ export function useCurrencyConverter() {
});
}, [isLoaded, updateSetting]);
const currencies: string[] = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
const rate = rates?.[to] ?? null;
const current = rates?.base === from ? rates.map : null;
const currencies: string[] = current ? Object.keys(current).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
const rate = current?.[to] ?? null;
// The pair is not known yet, or its rates are still on their way: neither is
// "rate unavailable", and neither may show a rate.
const pairPending = !isLoaded;
const ratePending = isLoaded && !current && failedBase !== from;
const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null;
const swap = () => {
setFrom(to);
setTo(from);
};
return { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates };
return {
from,
to,
setFrom,
setTo,
amount,
setAmount,
currencies,
rate,
converted,
swap,
fetchRates,
pairPending,
ratePending,
};
}
@@ -569,7 +569,9 @@ describe('ArtikelZeile — weight and bag', () => {
)
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } })
const weightField = container.querySelector('input[placeholder="—"]')!
fireEvent.change(weightField, { target: { value: '450' } })
fireEvent.blur(weightField)
await waitFor(() => expect(body).toMatchObject({ weight_grams: 450 }))
})
@@ -587,7 +589,9 @@ describe('ArtikelZeile — weight and bag', () => {
bagTrackingEnabled: true,
})
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '' } })
const weightField = container.querySelector('input[placeholder="—"]')!
fireEvent.change(weightField, { target: { value: '' } })
fireEvent.blur(weightField)
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
})
@@ -596,7 +600,9 @@ describe('ArtikelZeile — weight and bag', () => {
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
const { container } = setup({ bagTrackingEnabled: true })
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } })
const weightField = container.querySelector('input[placeholder="—"]')!
fireEvent.change(weightField, { target: { value: '450' } })
fireEvent.blur(weightField)
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
})
@@ -609,6 +615,7 @@ describe('ArtikelZeile — weight and bag', () => {
const weight = container.querySelector<HTMLInputElement>('input[placeholder="—"]')!
expect(weight.readOnly).toBe(true)
fireEvent.change(weight, { target: { value: '450' } })
fireEvent.blur(weight)
fireEvent.click(bagButton(container))
await waitFor(() => expect(screen.queryByRole('button', { name: /Carry-on/ })).toBeNull())
@@ -929,6 +936,7 @@ describe('ArtikelZeile — overflow menu', () => {
const weight = fieldOf(container, 'Total weight')
fireEvent.change(weight, { target: { value: '900' } })
fireEvent.blur(weight)
await waitFor(() => expect(bodies[0]).toMatchObject({ weight_grams: 900 }))
// The first entry mirrors the current bag and clears it when tapped.
@@ -945,6 +953,7 @@ describe('ArtikelZeile — overflow menu', () => {
fireEvent.click(overflowTrigger(container))
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '900' } })
fireEvent.blur(fieldOf(container, 'Total weight'))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
@@ -995,6 +1004,7 @@ describe('ArtikelZeile — overflow menu', () => {
fireEvent.click(overflowTrigger(container))
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '' } })
fireEvent.blur(fieldOf(container, 'Total weight'))
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
})
@@ -17,7 +17,7 @@ import PackingShareControl from './PackingShareControl'
import { PopoverItem } from './PackingPopover'
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
import type { TripMember } from './usePackingListPanel'
import { NumericInput } from '../shared/NumericInput'
import { WeightInput } from './PackingListPanelWeightInput'
import { bringersOf, packingBringerNames } from './packingBringers'
import { Tooltip } from '../shared/Tooltip'
import { BringerAvatar, BringersCard, MoreBringers } from './PackingBringersCard'
@@ -109,6 +109,10 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
catch { toast.error(t('common.error')) }
}
const saveWeight = async (grams: number | null) => {
try { await updatePackingItem(tripId, item.id, { weight_grams: grams }) } catch { toast.error(t('packing.toast.saveError')) }
}
const canDrag = canEdit && !isPlaceholder && !!drag
const selectedBag = bags.find(b => b.id === item.bag_id)
const quantity = item.quantity || 1
@@ -251,17 +255,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
{bagTrackingEnabled && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', ...badge(active || item.weight_grams != null) }}>
<NumericInput
value={item.weight_grams ?? ''}
readOnly={!canEdit}
onValueChange={async raw => {
if (!canEdit) return
const v = raw === '' ? null : Number.parseInt(raw)
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) }
}}
placeholder="—"
style={{ width: 36, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'end', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }}
/>
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={36} onSave={saveWeight} />
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
</div>
<div style={{ position: 'relative' }}>
@@ -381,16 +375,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '6px 8px' }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0 }}>{t('packing.totalWeight')}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent' }}>
<NumericInput
value={item.weight_grams ?? ''}
readOnly={!canEdit}
onValueChange={async raw => {
const v = raw === '' ? null : Number.parseInt(raw)
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) }
}}
placeholder="—"
style={{ width: 42, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'end', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }}
/>
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={42} onSave={saveWeight} />
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
</div>
</div>
@@ -0,0 +1,77 @@
// FE-COMP-PACKING-095 to FE-COMP-PACKING-098
import { useState } from 'react';
import { vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { WeightInput } from './PackingListPanelWeightInput';
/** A row whose stored weight changes only once a save answers, as the store does. */
function SlowRow({ onSave }: { onSave: (grams: number | null) => void }) {
const [stored, setStored] = useState<number | null>(null);
return (
<WeightInput
value={stored}
width={36}
onSave={(grams) => {
onSave(grams);
setTimeout(() => setStored(grams), 300);
}}
/>
);
}
describe('WeightInput', () => {
it('FE-COMP-PACKING-095: #3005 keeps every typed digit and saves the whole weight once', async () => {
const onSave = vi.fn();
render(<SlowRow onSave={onSave} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.focus(input);
for (const typed of ['2', '25', '250']) {
fireEvent.change(input, { target: { value: typed } });
expect(input.value).toBe(typed);
}
expect(onSave).not.toHaveBeenCalled();
fireEvent.blur(input);
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith(250);
await waitFor(() => expect(input.value).toBe('250'));
});
it('FE-COMP-PACKING-096: saves on Enter and stores an empty field as no weight', () => {
const onSave = vi.fn();
render(<WeightInput value={300} width={36} onSave={onSave} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
input.focus();
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSave).toHaveBeenCalledWith(null);
});
it('FE-COMP-PACKING-097: leaves an unchanged weight alone and follows a change from elsewhere', () => {
const onSave = vi.fn();
const { rerender } = render(<WeightInput value={300} width={36} onSave={onSave} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.focus(input);
fireEvent.blur(input);
expect(onSave).not.toHaveBeenCalled();
rerender(<WeightInput value={450} width={36} onSave={onSave} />);
expect(input.value).toBe('450');
});
it('FE-COMP-PACKING-098: a read-only field never saves', () => {
const onSave = vi.fn();
render(<WeightInput value={300} width={36} readOnly onSave={onSave} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: '9' } });
fireEvent.blur(input);
expect(onSave).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,65 @@
import { useState, type CSSProperties } from 'react';
import { NumericInput } from '../shared/NumericInput';
interface Props {
value: number | null | undefined;
onSave: (grams: number | null) => void | Promise<void>;
readOnly?: boolean;
width: number;
}
/**
* The weight of a packing item, in grams. It keeps what is typed in a draft of
* its own and saves once, on blur or Enter, the way the quantity does: saving
* every keystroke let a slow reply put the stored value back under the cursor,
* so the next digit went onto the old number and a fragment was saved.
*/
export function WeightInput({ value, onSave, readOnly, width }: Props) {
const stored = value == null ? '' : String(value);
// What is being typed, while the field has it; null shows the stored weight,
// so a change from elsewhere (another member) shows whenever nobody types.
const [draft, setDraft] = useState<string | null>(null);
// A saved weight until the store has it, so the field does not fall back to
// the old number for the length of the request.
const [pending, setPending] = useState<string | null>(null);
const shown = draft ?? pending ?? stored;
const commit = () => {
if (draft === null) return;
const typed = draft;
setDraft(null);
if (readOnly) return;
const grams = typed.trim() === '' ? null : Math.max(0, Number.parseInt(typed, 10) || 0);
const next = grams == null ? '' : String(grams);
if (next === stored) return;
setPending(next);
void Promise.resolve(onSave(grams)).finally(() => setPending(null));
};
const style: CSSProperties = {
width,
border: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
textAlign: 'end',
fontFamily: 'inherit',
outline: 'none',
color: 'var(--text-secondary)',
background: 'transparent',
padding: 0,
};
return (
<NumericInput
value={shown}
readOnly={readOnly}
onFocus={() => setDraft(shown)}
onValueChange={setDraft}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
}}
placeholder="—"
style={style}
/>
);
}
@@ -44,11 +44,11 @@ describe('Skeleton', () => {
})
describe('composed skeletons', () => {
it('FE-W4SKEL-006: SpotlightSkeleton renders the hero backdrop plus two bars', () => {
it('FE-W4SKEL-006: SpotlightSkeleton takes the box of the hero and renders the backdrop plus two bars', () => {
const { container } = render(<SpotlightSkeleton />)
expect(container.querySelectorAll('.trek-skeleton')).toHaveLength(3)
expect((container.firstElementChild as HTMLElement).style.minHeight).toBe('340px')
expect(container.firstElementChild).toHaveClass('hero-trip', 'hero-trip-pending')
})
it('FE-W4SKEL-007: TripCardSkeleton renders a cover plus two text bars', () => {
+6 -6
View File
@@ -26,13 +26,13 @@ export function Skeleton({
// Trip card skeleton matching SpotlightCard layout
export function SpotlightSkeleton(): React.ReactElement {
// The dashboard hero's own box (.hero-trip: 520px on desktop, 340px on a
// phone, and its margin), so the hero takes the place it held while loading
// and nothing under it moves when it arrives (#3009).
return (
<div
className="relative rounded-3xl overflow-hidden mb-8 bg-surface-tertiary"
style={{ minHeight: 340 }}
>
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
<div className="hero-trip hero-trip-pending" aria-hidden>
<div className="trek-skeleton absolute inset-0" />
<div className="relative flex h-full flex-col justify-end p-6">
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
<Skeleton width={220} height={16} radius={4} />
</div>
+4
View File
@@ -42,6 +42,7 @@ import { maybeInstallTouchDragPolyfill } from './utils/touchDragPolyfill'
import { startConnectivityProbe } from './sync/connectivity'
import { startNativeApp } from './native/nativeApp'
import { requestPersistentStorage } from './sync/persistentStorage'
import { isIosDevice } from './utils/iosDevice'
import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary'
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers'
import { schedulePrecacheRepair } from './utils/serviceWorkerShell'
@@ -52,6 +53,9 @@ startConnectivityProbe()
startNativeApp()
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
void requestPersistentStorage()
// A tile cache past the iOS budget slows every cold start of the home screen app (#3008).
// Loaded on iOS only, so the prefetcher stays out of everyone's first chunk.
if (isIosDevice()) void import('./sync/tilePrefetcher').then((m) => m.scheduleTileCacheTrim())
// Event handlers and async code never reach a boundary; this is where they land.
installGlobalErrorHandlers()
// A page the precache keeps under a stylesheet's or a script's name would be
+3 -1
View File
@@ -18,7 +18,9 @@ import type { Location, NavigationType } from 'react-router';
*/
export type RouteTransitionKind = 'forward' | 'back' | 'tab';
const SETTLE_MS = 800;
// Short on purpose: while it waits the browser shows a frozen snapshot of the old
// page, so on a slow connection a longer wait reads as the app hanging.
const SETTLE_MS = 350;
let skipNext = false;
let current: ViewTransition | null = null;
@@ -13,12 +13,12 @@ import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeat
*/
/**
* How long a trip may take to open before the splash comes up. The page
* transition holds the page the trip was opened from for up to 800ms
* transition holds the page the trip was opened from for up to 350ms
* (mobile/routeTransition.ts), so a trip that loads in that time slides in
* whole, the way the dashboard slides back in on the way out, and the splash is
* only for a slow connection.
* whole, the way the dashboard slides back in on the way out. A slower one gets
* the splash right after, so a slow connection never sees the app stand still.
*/
export const TRIP_SPLASH_AFTER_MS = 1000
export const TRIP_SPLASH_AFTER_MS = 400
/**
* While a trip opens. At first nothing but the screen's own background, marked
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { canSignInInApp, detectAuthGate } from './authGate';
const REQUESTED = 'https://trek.example.com/api/health/features';
const page = (html: string) => `<!doctype html><html><head><title>Sign in</title></head><body>${html}</body></html>`;
describe('detectAuthGate', () => {
it('names Cloudflare Access by its team domain and keeps that host for the sign-in', () => {
const gate = detectAuthGate(REQUESTED, {
status: 200,
url: 'https://team.cloudflareaccess.com/cdn-cgi/access/login/trek.example.com?redirect_url=%2F',
data: page('<form>Get a login code emailed to you</form>'),
});
expect(gate).toEqual({ kind: 'cloudflare', loginHost: 'team.cloudflareaccess.com' });
});
it('names Pangolin, authentik, Authelia and OAuth2 Proxy by their pages or paths', () => {
expect(
detectAuthGate(REQUESTED, {
status: 200,
url: 'https://pangolin.example.com/auth/resource/7?redirect=x',
data: page(''),
})
).toEqual({ kind: 'pangolin', loginHost: 'pangolin.example.com' });
expect(
detectAuthGate(REQUESTED, {
status: 200,
url: 'https://trek.example.com/outpost.goauthentik.io/start',
data: page(''),
})
).toEqual({ kind: 'authentik', loginHost: null });
expect(
detectAuthGate(REQUESTED, {
status: 200,
url: 'https://auth.example.com/?rd=x',
data: page('<div>Authelia</div>'),
})
).toEqual({ kind: 'authelia', loginHost: 'auth.example.com' });
expect(
detectAuthGate(REQUESTED, { status: 403, url: 'https://trek.example.com/oauth2/sign_in', data: page('') })
).toEqual({ kind: 'oauth2proxy', loginHost: null });
});
it("recognises a reverse proxy's password prompt, which cannot be signed in to inside the app", () => {
const gate = detectAuthGate(REQUESTED, {
status: 401,
url: REQUESTED,
data: '',
headers: { 'WWW-Authenticate': 'Basic realm="trek"' },
});
expect(gate).toEqual({ kind: 'basic', loginHost: null });
expect(gate !== null && canSignInInApp(gate)).toBe(false);
});
it('calls any other sign-in page an unknown gate', () => {
const gate = detectAuthGate(REQUESTED, {
status: 200,
url: 'https://sso.example.com/login',
data: page('<input type="password">'),
});
expect(gate).toEqual({ kind: 'unknown', loginHost: 'sso.example.com' });
expect(gate !== null && canSignInInApp(gate)).toBe(true);
});
it('sees no gate in a server that is simply something else', () => {
expect(detectAuthGate(REQUESTED, { status: 200, url: REQUESTED, data: { status: 'ok' } })).toBeNull();
expect(
detectAuthGate(REQUESTED, { status: 404, url: REQUESTED, data: page('<h1>Welcome to nginx</h1>') })
).toBeNull();
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* Self-hosters often put a sign-in page of their own in front of TREK: an
* identity-aware proxy such as Cloudflare Access, Pangolin, Authentik,
* Authelia or oauth2-proxy, or a reverse proxy's password prompt. The app's
* server check then gets that page instead of TREK's answer and used to call
* the server "not TREK". This reads which gate it met, so the address screen
* can say so, and offers its sign-in inside the app where that can work.
*/
export type AuthGateKind = 'cloudflare' | 'pangolin' | 'authentik' | 'authelia' | 'oauth2proxy' | 'basic' | 'unknown';
export interface AuthGate {
kind: AuthGateKind;
/**
* The host the gate's sign-in page lives on, when the check was sent there.
* Only these hosts are let into the app's web view while signing in; every
* other foreign page still opens in the system browser.
*/
loginHost: string | null;
}
/** The product names the address screen shows; not translated. */
export const AUTH_GATE_NAMES: Record<Exclude<AuthGateKind, 'unknown' | 'basic'>, string> = {
cloudflare: 'Cloudflare Access',
pangolin: 'Pangolin',
authentik: 'authentik',
authelia: 'Authelia',
oauth2proxy: 'OAuth2 Proxy',
};
export interface GateResponse {
status: number;
data: unknown;
/** The URL after redirects. */
url: string;
headers?: Record<string, string>;
}
function header(headers: Record<string, string> | undefined, name: string): string {
if (!headers) return '';
const wanted = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === wanted) return String(value);
return '';
}
function hostOf(url: string): string | null {
try {
return new URL(url).host.toLowerCase();
} catch {
return null;
}
}
/** A page that asks for a sign-in: a password field, or the words for it. */
function looksLikeSignIn(html: string): boolean {
return /type=["']?password/i.test(html) || /\b(sign|log)[\s-]?in\b/i.test(html);
}
/** What the check got back, read once for the rules below. */
interface Seen {
host: string | null;
path: string;
html: string;
status: number;
headers: Record<string, string> | undefined;
}
/** The named gates, each by what gives it away; the first that matches wins. */
const NAMED_GATES: Array<[Exclude<AuthGateKind, 'basic' | 'unknown'>, (seen: Seen) => boolean]> = [
[
'cloudflare',
(seen) => !!seen.host?.endsWith('.cloudflareaccess.com') || !!header(seen.headers, 'cf-access-domain'),
],
['authentik', (seen) => seen.path.includes('/outpost.goauthentik.io/') || /authentik/i.test(seen.html)],
['authelia', (seen) => /authelia/i.test(seen.html)],
['oauth2proxy', (seen) => seen.path.startsWith('/oauth2/') || /oauth2[\s-]?proxy/i.test(seen.html)],
['pangolin', (seen) => seen.path.startsWith('/auth/resource/') || /pangolin/i.test(seen.html)],
];
function pathOf(url: string): string {
try {
return new URL(url).pathname;
} catch {
return '';
}
}
/** A gate that gives no name away: a password prompt, or any other page that asks for a sign-in. */
function unnamedGate(seen: Seen, loginHost: string | null): AuthGate | null {
if (seen.status === 401 && /^basic\b/i.test(header(seen.headers, 'www-authenticate'))) {
return { kind: 'basic', loginHost: null };
}
// Only a page that reads as a sign-in counts: a server that is simply
// something other than TREK answers with a page of its own too.
if (!seen.html || !looksLikeSignIn(seen.html)) return null;
return loginHost || [200, 401, 403].includes(seen.status) ? { kind: 'unknown', loginHost } : null;
}
/**
* The gate in front of `requested`, judged from what came back instead of
* TREK's answer, or null when nothing points to one: then the address simply
* is not a TREK server.
*/
export function detectAuthGate(requested: string, response: GateResponse): AuthGate | null {
const finalUrl = response.url || requested;
const host = hostOf(finalUrl);
const loginHost = host !== null && host !== hostOf(requested) ? host : null;
const seen: Seen = {
host,
path: pathOf(finalUrl),
html: typeof response.data === 'string' ? response.data : '',
status: response.status,
headers: response.headers,
};
const named = NAMED_GATES.find(([, matches]) => matches(seen));
return named ? { kind: named[0], loginHost } : unnamedGate(seen, loginHost);
}
/** Whether the app can show this gate's sign-in itself: a password prompt it cannot. */
export function canSignInInApp(gate: AuthGate): boolean {
return gate.kind !== 'basic';
}
+49
View File
@@ -1,17 +1,24 @@
import { sha256 } from '@noble/hashes/sha2';
import { bytesToHex } from '@noble/hashes/utils';
import { startAuthentication } from '@simplewebauthn/browser';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authApi } from '../api/client';
import { nativeAuthApi } from '../api/nativeAuth';
import {
BrowserSignInCancelled,
createHandoffSecret,
handoffUrl,
rememberHandoffReturn,
requestHandoffCode,
signInThroughBrowser,
signInWithPasskey,
takeHandoffReturn,
} from './browserSignIn';
import { TrekShell } from './trekShell';
vi.mock('./trekShell', () => ({ APP_URL_SCHEME: 'com.liketrek.trek', TrekShell: { authenticate: vi.fn() } }));
vi.mock('@simplewebauthn/browser', () => ({ startAuthentication: vi.fn() }));
vi.mock('../api/client', () => ({ authApi: { passkey: { loginOptions: vi.fn(), loginVerify: vi.fn() } } }));
vi.mock('../api/nativeAuth', () => ({ nativeAuthApi: { exchange: vi.fn(), handoff: vi.fn() } }));
beforeEach(() => {
@@ -77,3 +84,45 @@ describe('signInThroughBrowser', () => {
expect(nativeAuthApi.exchange).not.toHaveBeenCalled();
});
});
describe('the remembered handoff return', () => {
const path = `/native-handoff?challenge=${'a'.repeat(64)}`;
beforeEach(() => {
localStorage.clear();
vi.useRealTimers();
});
it('survives a sign-in that comes back in another tab, once', () => {
rememberHandoffReturn(path);
expect(takeHandoffReturn()).toBe(path);
expect(takeHandoffReturn()).toBeNull();
});
it('is dropped once it is older than a quarter of an hour', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-11-08T12:00:00Z'));
rememberHandoffReturn(path);
vi.setSystemTime(new Date('2026-11-08T12:16:00Z'));
expect(takeHandoffReturn()).toBeNull();
vi.useRealTimers();
});
it('only ever leads back to the handoff page', () => {
rememberHandoffReturn('/admin');
expect(takeHandoffReturn()).toBeNull();
localStorage.setItem('trek_native_handoff_return', 'not json');
expect(takeHandoffReturn()).toBeNull();
});
});
describe('signInWithPasskey', () => {
it('asks for the passkey and has the server verify it', async () => {
vi.mocked(authApi.passkey.loginOptions).mockResolvedValue({ challenge: 'x' });
vi.mocked(startAuthentication).mockResolvedValue({ id: 'cred' } as never);
vi.mocked(authApi.passkey.loginVerify).mockResolvedValue({ token: '', user: {} });
await signInWithPasskey();
expect(startAuthentication).toHaveBeenCalledWith({ optionsJSON: { challenge: 'x' } });
expect(authApi.passkey.loginVerify).toHaveBeenCalledWith({ id: 'cred' });
});
});
+45 -1
View File
@@ -1,5 +1,7 @@
import { sha256 } from '@noble/hashes/sha2';
import { bytesToHex } from '@noble/hashes/utils';
import { startAuthentication } from '@simplewebauthn/browser';
import { authApi } from '../api/client';
import { nativeAuthApi } from '../api/nativeAuth';
import { APP_URL_SCHEME, TrekShell } from './trekShell';
@@ -19,7 +21,42 @@ import { APP_URL_SCHEME, TrekShell } from './trekShell';
export const HANDOFF_PATH = '/native-handoff';
export type BrowserSignInStart = 'oidc';
/** Where the browser starts: straight into SSO, or straight at the passkey prompt. */
export type BrowserSignInStart = 'oidc' | 'passkey';
const HANDOFF_RETURN_KEY = 'trek_native_handoff_return';
const HANDOFF_RETURN_TTL_MS = 15 * 60 * 1000;
/**
* Remembers the handoff page an SSO round trip has to come back to. The login
* keeps its return path in sessionStorage, which is per tab, and an identity
* provider such as Google may finish its sign-in in another tab or in its own
* app on Android. The return then arrives without it and the user lands in the
* web app instead of being handed back, so the handoff page also leaves a short
* lived copy here.
*/
export function rememberHandoffReturn(path: string): void {
try {
localStorage.setItem(HANDOFF_RETURN_KEY, JSON.stringify({ path, at: Date.now() }));
} catch {
/* storage blocked: the sessionStorage copy still covers a single-tab sign-in */
}
}
/** The remembered handoff page, once: only a fresh path to the handoff page itself. */
export function takeHandoffReturn(): string | null {
try {
const raw = localStorage.getItem(HANDOFF_RETURN_KEY);
localStorage.removeItem(HANDOFF_RETURN_KEY);
if (!raw) return null;
const { path, at } = JSON.parse(raw) as { path?: unknown; at?: unknown };
if (typeof path !== 'string' || typeof at !== 'number') return null;
if (Date.now() - at > HANDOFF_RETURN_TTL_MS) return null;
return path.startsWith(`${HANDOFF_PATH}?challenge=`) ? path : null;
} catch {
return null;
}
}
export function createHandoffSecret(): { verifier: string; challenge: string } {
const bytes = crypto.getRandomValues(new Uint8Array(32));
@@ -41,6 +78,13 @@ export async function requestHandoffCode(challenge: string): Promise<string> {
return code;
}
/** The browser side of a passkey sign-in the app started: the prompt, then the session cookie. */
export async function signInWithPasskey(): Promise<void> {
const options = await authApi.passkey.loginOptions();
const assertion = await startAuthentication({ optionsJSON: options });
await authApi.passkey.loginVerify(assertion);
}
export class BrowserSignInCancelled extends Error {
constructor() {
super('Sign-in cancelled');
+3
View File
@@ -9,6 +9,9 @@ export const PRIVACY_URL = 'https://liketrek.com/privacy?app=1';
/** What a TREK server is and how to run one, for someone who has none yet. */
export const SERVER_DOCS_URL = 'https://github.com/liketrek/TREK#readme';
/** Running the app with a sign-in proxy (Cloudflare Access, Pangolin and the like) in front of TREK. */
export const AUTH_PROXY_DOCS_URL = 'https://github.com/liketrek/TREK/wiki/Native-Apps#behind-a-sign-in-proxy';
/** The public demo the address screen offers to someone without a server. */
export const DEMO_SERVER_URL = 'https://demo.liketrek.com';
+38
View File
@@ -103,3 +103,41 @@ describe('probeDemo', () => {
expect(get).toHaveBeenCalledWith('https://demo.liketrek.com/api/health/features');
});
});
describe('probeServer behind a sign-in proxy', () => {
it('reports the gate, the server it guards and the host its sign-in lives on', async () => {
const get = vi.fn().mockResolvedValue({
status: 200,
url: 'https://pangolin.example.com/auth/resource/3?redirect=https%3A%2F%2Ftrek.example.com',
data: '<html><title>Pangolin</title><form><input type="password"></form></html>',
} satisfies ProbeResponse);
expect(await probeServer('trek.example.com', get)).toEqual({
ok: false,
reason: 'authGate',
url: 'https://trek.example.com',
gate: { kind: 'pangolin', loginHost: 'pangolin.example.com' },
});
});
it('asks the start page for the sign-in host when the API path only got a 401', async () => {
const get = vi.fn(async (url: string): Promise<ProbeResponse> =>
url.endsWith('/api/health/features')
? { status: 401, url, data: '<html>Authelia</html>' }
: {
status: 200,
url: 'https://auth.example.com/?rd=https%3A%2F%2Ftrek.example.com%2F',
data: '<html>Authelia</html>',
}
);
const result = await probeServer('https://trek.example.com', get);
expect(result).toMatchObject({ reason: 'authGate', gate: { kind: 'authelia', loginHost: 'auth.example.com' } });
expect(get).toHaveBeenCalledWith('https://trek.example.com/');
});
it('keeps calling an address that is simply something else not TREK', async () => {
const get = vi
.fn()
.mockResolvedValue({ status: 200, url: 'https://x.example.com/api/health/features', data: { hello: 1 } });
expect(await probeServer('https://x.example.com', get)).toEqual({ ok: false, reason: 'notTrek' });
});
});
+39 -8
View File
@@ -1,3 +1,4 @@
import { detectAuthGate, type AuthGate } from './authGate';
import { DEMO_SERVER_URL } from './links';
/**
@@ -5,14 +6,21 @@ import { DEMO_SERVER_URL } from './links';
* can load, or a reason it cannot.
*/
export type ProbeFailure = 'invalid' | 'unreachable' | 'notTrek' | 'tooOld';
type ProbeResult = { ok: true; url: string; reason?: undefined } | { ok: false; reason: ProbeFailure; url?: undefined };
export type ProbeFailure = 'invalid' | 'unreachable' | 'notTrek' | 'authGate' | 'tooOld';
type ProbeResult =
| { ok: true; url: string; reason?: undefined; gate?: undefined }
/**
* `authGate`: a sign-in page of the user's own stands in front of TREK
* (authGate.ts); `url` is the server address it guards.
*/
| { ok: false; reason: ProbeFailure; url?: string; gate?: AuthGate };
export interface ProbeResponse {
status: number;
data: unknown;
/** The URL after redirects. */
url: string;
headers?: Record<string, string>;
}
export type ProbeGet = (url: string) => Promise<ProbeResponse>;
@@ -66,24 +74,47 @@ function judge(response: ProbeResponse): ProbeResult {
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 };
const FAILURE_RANK: Record<ProbeFailure, number> = { invalid: 0, unreachable: 1, notTrek: 2, authGate: 3, tooOld: 4 };
/**
* A gate that did not send the check to its sign-in page (an API path can get a
* bare 401 where a browser gets redirected) is asked once more for the start
* page, which is what the app's web view will load, to learn the host its
* sign-in lives on.
*/
async function withLoginHost(candidate: string, gate: AuthGate, get: ProbeGet): Promise<AuthGate> {
if (gate.loginHost || gate.kind === 'basic') return gate;
try {
const page = await get(candidate + '/');
const found = detectAuthGate(candidate + '/', page);
return found?.loginHost ? { ...gate, loginHost: found.loginHost } : gate;
} catch {
return gate;
}
}
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';
let best: ProbeResult = { ok: false, reason: 'unreachable' };
for (const candidate of candidates) {
let verdict: ProbeResult;
let response: ProbeResponse;
try {
verdict = judge(await get(candidate + FEATURES_PATH));
response = await get(candidate + FEATURES_PATH);
} catch {
continue;
}
let verdict = judge(response);
if (verdict.ok) return { ok: true, url: verdict.url || candidate };
if (FAILURE_RANK[verdict.reason] > FAILURE_RANK[failure]) failure = verdict.reason;
if (verdict.reason === 'notTrek') {
const gate = detectAuthGate(candidate + FEATURES_PATH, response);
if (gate)
verdict = { ok: false, reason: 'authGate', url: candidate, gate: await withLoginHost(candidate, gate, get) };
}
if (!best.ok && FAILURE_RANK[verdict.reason] > FAILURE_RANK[best.reason]) best = verdict;
}
return { ok: false, reason: failure };
return best;
}
/** The address screen's "Try the demo": the same probe, against the public demo server. */
+6 -2
View File
@@ -14,8 +14,12 @@ import type { NativeCapabilities } from './capabilities';
*/
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>;
/**
* Remembers the server and loads it. The current page is gone afterwards.
* `signInHosts` are the hosts of a proxy's sign-in page in front of it
* (authGate.ts): only they may load in the web view besides the server.
*/
setServer(options: { url: string; signInHosts?: 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. */
@@ -67,6 +67,8 @@ beforeEach(() => {
vi.setSystemTime(new Date('2026-07-05T12:00:00Z'));
installMatchMedia();
resetAllStores();
// The currency tool waits for the saved pair (#3009); the page renders with settings in place.
useSettingsStore.setState({ isLoaded: true });
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
usePluginStore.setState({ plugins: [], loaded: true });
onlyTrips([TRIP]);
+8 -5
View File
@@ -544,7 +544,7 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.tripsTotal')}</div>
<div className="value mono">{statsPending ? <Skeleton width={44} height={30} radius={6} /> : stats.totalTrips ?? 0}</div>
<div className="delta">{statsPending ? <Skeleton width={90} height={12} /> : t('dashboard.atlas.placesMapped', { count: stats.totalPlaces ?? 0 })}</div>
<div className="delta">{statsPending ? <span className="trek-skeleton delta-pending">{t('dashboard.atlas.placesMapped', { count: 0 })}</span> : t('dashboard.atlas.placesMapped', { count: stats.totalPlaces ?? 0 })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<polyline points="0,30 12,26 22,28 32,18 44,22 56,10 68,14 80,4" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
@@ -572,7 +572,7 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
{statsPending ? <Skeleton width={60} height={30} radius={6} /> : distanceText}
{' '}<span className="unit">{distanceLabel}</span>
</div>
<div className="delta">{statsPending ? <Skeleton width={110} height={12} /> : t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<div className="delta">{statsPending ? <span className="trek-skeleton delta-pending">{t('dashboard.atlas.aroundEquator', { count: '0.00' })}</span> : t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<circle cx="40" cy="18" r="14" fill="none" stroke="oklch(0.88 0.01 70)" strokeWidth="2" />
<circle cx="40" cy="18" r="14" fill="none" strokeWidth="2" strokeDasharray="58 88" strokeLinecap="round" transform="rotate(-90 40 18)" />
@@ -662,7 +662,7 @@ function TripCard({ trip, locale, badges, matchedPlaces, onOpen, onEdit, onCopy,
// ── Currency tool (self-contained, mirrors the design's fx widget) ───────────
function CurrencyTool(): React.ReactElement {
const { t } = useTranslation()
const { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates: fetchRate } = useCurrencyConverter()
const { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates: fetchRate, pairPending, ratePending } = useCurrencyConverter()
const ccyOptions = currencies.map(c => ({ value: c, label: c }))
return (
@@ -671,7 +671,8 @@ function CurrencyTool(): React.ReactElement {
<div className="tool-title"><RefreshCw size={14} /> {t('dashboard.currency')}</div>
<button type="button" className="tool-action" aria-label={t('dashboard.aria.refreshRates')} onClick={fetchRate}><RefreshCw size={14} /></button>
</div>
<div className="fx-input">
{/* Held invisible until the saved pair is known, so the defaults never flash first (#3009). */}
<div className="fx-input" style={pairPending ? { visibility: 'hidden' } : undefined}>
<div className="fx-field">
<div className="lbl">{t('dashboard.fx.from')}</div>
<input className="amt mono" value={amount} onChange={e => setAmount(e.target.value)} inputMode="decimal" />
@@ -685,7 +686,9 @@ function CurrencyTool(): React.ReactElement {
</div>
</div>
<div className="fx-rate">
<span>{rate != null ? `1 ${from} = ${rate.toFixed(4)} ${to}` : t('dashboard.fx.unavailable')}</span>
{pairPending || ratePending
? <span className="trek-skeleton" aria-hidden>{`1 ${from} = 0.0000 ${to}`}</span>
: <span>{rate != null ? `1 ${from} = ${rate.toFixed(4)} ${to}` : t('dashboard.fx.unavailable')}</span>}
</div>
</div>
)
+2 -2
View File
@@ -228,8 +228,8 @@ export default function LoginPage(): React.ReactElement {
return (
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: 'var(--font-system)', position: 'relative' }}>
{/* Language dropdown */}
<div style={{ position: 'absolute', top: 16, insetInlineEnd: 16, zIndex: 10 }}>
{/* Language dropdown, below the status bar in the app and under a notch, not behind them */}
<div className="absolute end-4 top-[calc(env(safe-area-inset-top,0px)+16px)] z-10">
<button type="button"
onClick={(e) => {
e.stopPropagation();
+14 -1
View File
@@ -1,4 +1,4 @@
// FE-NATIVE-HANDOFF-PAGE-001 to FE-NATIVE-HANDOFF-PAGE-004
// FE-NATIVE-HANDOFF-PAGE-001 to FE-NATIVE-HANDOFF-PAGE-005
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../tests/helpers/render';
import NativeHandoffPage from './NativeHandoffPage';
@@ -8,6 +8,9 @@ vi.mock('./nativeHandoff/useNativeHandoff', () => ({ useNativeHandoff: vi.fn() }
const base = {
valid: true,
passkeyStep: false,
continueWithPasskey: vi.fn(),
otherSignIn: vi.fn(),
ready: true,
userName: 'maria',
status: 'idle' as const,
@@ -52,4 +55,14 @@ describe('NativeHandoffPage', () => {
render(<NativeHandoffPage />);
expect(screen.getByText('Signing in did not work. Please try again.')).toBeInTheDocument();
});
it('FE-NATIVE-HANDOFF-PAGE-005: asks for the passkey itself when the app started a passkey sign-in', () => {
vi.mocked(useNativeHandoff).mockReturnValue({ ...base, passkeyStep: true, ready: false });
render(<NativeHandoffPage />);
const buttons = screen.getAllByRole('button');
fireEvent.click(buttons[0]);
expect(base.continueWithPasskey).toHaveBeenCalled();
fireEvent.click(buttons[1]);
expect(base.otherSignIn).toHaveBeenCalled();
});
});
+38 -10
View File
@@ -1,17 +1,49 @@
import { Smartphone } from 'lucide-react';
import { Fingerprint, Smartphone } from 'lucide-react';
import { useTranslation } from '../i18n';
import { useNativeHandoff } from './nativeHandoff/useNativeHandoff';
const PRIMARY = 'w-full rounded-lg bg-accent py-3 font-semibold text-accent-text disabled:opacity-60';
const SECONDARY = 'py-1 text-caption text-content-muted';
export default function NativeHandoffPage() {
const { t } = useTranslation();
const { valid, ready, userName, status, openApp, switchAccount } = useNativeHandoff();
const { valid, passkeyStep, continueWithPasskey, otherSignIn, ready, userName, status, openApp, switchAccount } =
useNativeHandoff();
const failed = status === 'failed' && (
<p role="alert" className="text-body text-danger">
{t(passkeyStep ? 'login.passkey.failed' : 'native.login.failed')}
</p>
);
return (
<div className="flex min-h-screen items-center justify-center bg-surface-secondary p-6">
<div className="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-edge bg-surface-card p-7 text-center">
<Smartphone className="mx-auto text-content-muted" size={32} aria-hidden />
{passkeyStep ? (
<Fingerprint className="mx-auto text-content-muted" size={32} aria-hidden />
) : (
<Smartphone className="mx-auto text-content-muted" size={32} aria-hidden />
)}
{!valid ? (
<p className="text-body text-content">{t('native.login.failed')}</p>
) : passkeyStep ? (
<>
<h1 className="text-title font-semibold text-content">{t('login.passkey.signIn')}</h1>
{failed}
<button
type="button"
onClick={() => {
void continueWithPasskey();
}}
disabled={status === 'sending'}
className={PRIMARY}
>
{t('login.passkey.signIn')}
</button>
<button type="button" onClick={otherSignIn} className={SECONDARY}>
{t('native.handoff.switchAccount')}
</button>
</>
) : !ready ? (
<div
className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-edge border-t-accent"
@@ -21,18 +53,14 @@ export default function NativeHandoffPage() {
<>
<h1 className="text-title font-semibold text-content">{t('native.handoff.title')}</h1>
<p className="text-body text-content-muted">{t('native.handoff.hint', { name: userName })}</p>
{status === 'failed' && (
<p role="alert" className="text-body text-danger">
{t('native.login.failed')}
</p>
)}
{failed}
<button
type="button"
onClick={() => {
void openApp();
}}
disabled={status === 'sending'}
className="w-full rounded-lg bg-accent py-3 font-semibold text-accent-on disabled:opacity-60"
className={PRIMARY}
>
{t('native.handoff.open')}
</button>
@@ -41,7 +69,7 @@ export default function NativeHandoffPage() {
onClick={() => {
void switchAccount();
}}
className="py-1 text-caption text-content-muted"
className={SECONDARY}
>
{t('native.handoff.switchAccount')}
</button>
@@ -106,7 +106,7 @@ describe('useLogin in the native app', () => {
await result.current.handlePasskeyLogin();
});
expect(signInThroughBrowser).toHaveBeenCalledWith(undefined);
expect(signInThroughBrowser).toHaveBeenCalledWith('passkey');
expect(startAuthentication).not.toHaveBeenCalled();
});
+10 -3
View File
@@ -10,7 +10,12 @@ import { getApiErrorMessage } from '../../types'
import { START_DESTINATION_ROUTE } from '../../utils/startDestination'
import { passwordErrorKey } from '../../utils/passwordError'
import { isNativeApp } from '../../native/platform'
import { BrowserSignInCancelled, signInThroughBrowser, type BrowserSignInStart } from '../../native/browserSignIn'
import {
BrowserSignInCancelled,
signInThroughBrowser,
takeHandoffReturn,
type BrowserSignInStart,
} from '../../native/browserSignIn'
interface AppConfig {
has_users: boolean
@@ -221,7 +226,9 @@ export function useLogin() {
// body is never read, so it can go without breaking SSO sign-in.
if (ok && data.success === true) {
await loadUser()
const savedRedirect = sessionStorage.getItem('oidc_redirect') || START_DESTINATION_ROUTE
// Taken either way, so a handoff left from an abandoned sign-in cannot catch a later one.
const handoffReturn = takeHandoffReturn()
const savedRedirect = sessionStorage.getItem('oidc_redirect') || handoffReturn || START_DESTINATION_ROUTE
sessionStorage.removeItem('oidc_redirect')
navigate(savedRedirect, { replace: true })
} else {
@@ -319,7 +326,7 @@ export function useLogin() {
}
const handlePasskeyLogin = async (): Promise<void> => {
if (nativeApp) return handleBrowserSignIn()
if (nativeApp) return handleBrowserSignIn('passkey')
setError('')
setIsLoading(true)
try {
@@ -1,10 +1,10 @@
// FE-NATIVE-HANDOFF-001 to FE-NATIVE-HANDOFF-008
// FE-NATIVE-HANDOFF-001 to FE-NATIVE-HANDOFF-011
import React from 'react';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { requestHandoffCode } from '../../native/browserSignIn';
import { rememberHandoffReturn, requestHandoffCode, signInWithPasskey } from '../../native/browserSignIn';
import { useAuthStore } from '../../store/authStore';
import { useNativeHandoff } from './useNativeHandoff';
@@ -13,7 +13,12 @@ vi.mock('react-router', async () => {
const actual = await vi.importActual<typeof import('react-router')>('react-router');
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('../../native/browserSignIn', () => ({ HANDOFF_PATH: '/native-handoff', requestHandoffCode: vi.fn() }));
vi.mock('../../native/browserSignIn', () => ({
HANDOFF_PATH: '/native-handoff',
rememberHandoffReturn: vi.fn(),
requestHandoffCode: vi.fn(),
signInWithPasskey: vi.fn(),
}));
const challenge = 'ab'.repeat(32);
const realLocation = window.location;
@@ -85,6 +90,7 @@ describe('useNativeHandoff', () => {
renderHandoff();
expect(window.location.replace).toHaveBeenCalledWith('/api/auth/oidc/login?remember=1');
expect(sessionStorage.getItem('oidc_redirect')).toBe(`/native-handoff?challenge=${challenge}`);
expect(rememberHandoffReturn).toHaveBeenCalledWith(`/native-handoff?challenge=${challenge}`);
});
it('FE-NATIVE-HANDOFF-005: waits while the session is still being checked', () => {
@@ -129,4 +135,53 @@ describe('useNativeHandoff', () => {
)
);
});
describe('when the app asked for a passkey', () => {
const loadUser = vi.fn(async () => {
useAuthStore.setState({ isAuthenticated: true });
});
beforeEach(() => {
setSearch(`?challenge=${challenge}&start=passkey`);
useAuthStore.setState({ isAuthenticated: false, loadUser });
loadUser.mockClear();
vi.mocked(signInWithPasskey).mockReset().mockResolvedValue();
});
it('FE-NATIVE-HANDOFF-009: asks for the passkey itself instead of sending the user to the login', () => {
const { result } = renderHandoff();
expect(result.current.passkeyStep).toBe(true);
expect(mockNavigate).not.toHaveBeenCalled();
});
it('FE-NATIVE-HANDOFF-010: signs in with the passkey and goes straight back to the app', async () => {
vi.mocked(requestHandoffCode).mockResolvedValue('c1');
const { result } = renderHandoff();
await act(async () => {
await result.current.continueWithPasskey();
});
expect(signInWithPasskey).toHaveBeenCalled();
expect(loadUser).toHaveBeenCalled();
expect(requestHandoffCode).toHaveBeenCalledWith(challenge);
expect(window.location.href).toBe('com.liketrek.trek://auth?code=c1');
});
it('FE-NATIVE-HANDOFF-011: stays quiet when the prompt is dismissed and offers the full login on request', async () => {
vi.mocked(signInWithPasskey).mockRejectedValue(Object.assign(new Error('no'), { name: 'NotAllowedError' }));
const { result } = renderHandoff();
await act(async () => {
await result.current.continueWithPasskey();
});
expect(result.current.status).toBe('idle');
expect(requestHandoffCode).not.toHaveBeenCalled();
act(() => result.current.otherSignIn());
await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith(
`/login?redirect=${encodeURIComponent(`/native-handoff?challenge=${challenge}`)}`,
{ replace: true }
)
);
});
});
});
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useShallow } from 'zustand/react/shallow';
import { HANDOFF_PATH, requestHandoffCode } from '../../native/browserSignIn';
import { HANDOFF_PATH, rememberHandoffReturn, requestHandoffCode, signInWithPasskey } from '../../native/browserSignIn';
import { APP_URL_SCHEME } from '../../native/trekShell';
import { useAuthStore } from '../../store/authStore';
@@ -15,31 +15,36 @@ const CHALLENGE = /^[0-9a-f]{64}$/;
*/
export function useNativeHandoff() {
const navigate = useNavigate();
const { isAuthenticated, isLoading, userName, logout } = useAuthStore(
const { isAuthenticated, isLoading, userName, logout, loadUser } = useAuthStore(
useShallow((s) => ({
isAuthenticated: s.isAuthenticated,
isLoading: s.isLoading,
userName: s.user?.username ?? '',
logout: s.logout,
loadUser: s.loadUser,
}))
);
const [params] = useState(() => new URLSearchParams(window.location.search));
const challenge = params.get('challenge') ?? '';
const valid = CHALLENGE.test(challenge);
const [status, setStatus] = useState<'idle' | 'sending' | 'failed'>('idle');
// The app asked for a passkey: this page asks for it itself instead of showing
// the whole login a second time, until the user picks another way to sign in.
const [passkeyStep, setPasskeyStep] = useState(() => params.get('start') === 'passkey');
// Back here once signed in, without `start`, so an SSO round trip cannot loop.
const returnPath = `${HANDOFF_PATH}?challenge=${challenge}`;
useEffect(() => {
if (!valid || isLoading || isAuthenticated) return;
if (!valid || isLoading || isAuthenticated || passkeyStep) return;
if (params.get('start') === 'oidc') {
sessionStorage.setItem('oidc_redirect', returnPath);
rememberHandoffReturn(returnPath);
window.location.replace('/api/auth/oidc/login?remember=1');
return;
}
void navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true });
}, [valid, isLoading, isAuthenticated, params, returnPath, navigate]);
}, [valid, isLoading, isAuthenticated, passkeyStep, params, returnPath, navigate]);
const openApp = async (): Promise<void> => {
setStatus('sending');
@@ -51,6 +56,28 @@ export function useNativeHandoff() {
}
};
/**
* The passkey prompt, then straight back to the app: the tap that started it
* is the explicit consent the handoff asks for, so no second page follows.
*/
const continueWithPasskey = async (): Promise<void> => {
setStatus('sending');
try {
await signInWithPasskey();
await loadUser({ silent: true });
} catch (err: unknown) {
const name = (err as { name?: string })?.name;
setStatus(name === 'NotAllowedError' || name === 'AbortError' ? 'idle' : 'failed');
return;
}
await openApp();
};
const otherSignIn = (): void => {
setStatus('idle');
setPasskeyStep(false);
};
const switchAccount = async (): Promise<void> => {
await logout();
void navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true });
@@ -58,6 +85,9 @@ export function useNativeHandoff() {
return {
valid,
passkeyStep: valid && !isLoading && !isAuthenticated && passkeyStep,
continueWithPasskey,
otherSignIn,
ready: valid && !isLoading && isAuthenticated,
userName,
status,
+10 -1
View File
@@ -175,6 +175,9 @@
transition: transform .35s cubic-bezier(0.23,1,0.32,1), box-shadow .35s cubic-bezier(0.23,1,0.32,1);
}
.trek-dash .hero-trip:hover { transform: translateY(-4px); box-shadow: var(--sh-xl, 0 24px 60px oklch(0 0 0 / .28)); }
/* The hero's place while the trips load: same box, nothing to open or lift (#3009). */
.trek-dash .hero-trip.hero-trip-pending { cursor: default; box-shadow: none; }
.trek-dash .hero-trip.hero-trip-pending:hover { transform: none; box-shadow: none; }
.trek-dash .hero-trip img.bg {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; z-index: 0; transition: transform 12s ease-out;
@@ -400,7 +403,7 @@
.trek-dash .atlas-card.passport .label { color: oklch(1 0 0 / .65); }
.trek-dash .atlas-card.passport .value { font-size: 56px; }
.trek-dash .atlas-card.passport .delta { color: oklch(1 0 0 / .65); }
.trek-dash .passport-flags { display: flex; margin-top: 18px; }
.trek-dash .passport-flags { display: flex; margin-top: 18px; min-height: 24px; } /* a flag's height, so the flags arriving move nothing (#3009) */
.trek-dash .flag {
width: 24px; height: 24px; border-radius: 50%; overflow: hidden;
background: oklch(0.4 0.06 60); border: 2px solid oklch(0.28 0.04 50);
@@ -665,6 +668,8 @@
cursor: pointer; text-align: start; box-shadow: var(--sh-sm); transition: transform .15s cubic-bezier(.2,.7,.2,1), box-shadow .2s;
}
.trek-dash .col-badge:hover { transform: translateY(-2px); box-shadow: var(--sh-md); }
.trek-dash .col-badge.col-badge-pending { cursor: default; box-shadow: none; }
.trek-dash .col-badge.col-badge-pending:hover { transform: none; box-shadow: none; }
.trek-dash .col-badge-media { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background-size: cover; background-position: center; }
.trek-dash .col-badge-tint {
position: absolute; inset: 0;
@@ -806,3 +811,7 @@
/* The bottom tab bar's centre "+" replaces the floating FAB on mobile */
.trek-dash .fab-new-trip { display: none; }
}
/* A figure's line while the stats load: its real text, invisible under the
shimmer, so it wraps as the loaded line will and the cards keep their height (#3009). */
.trek-dash .atlas-card .delta .delta-pending { color: transparent; border-radius: 4px; }
+63 -3
View File
@@ -31,6 +31,7 @@ import { isOfflineForbiddenTemplate, isVectorStyle, normalizeTileUrl, resolveTil
import { OFM_DARK, OFM_POSITRON } from '../constants/mapDefaults'
import { clearVectorCache, prefetchStyleAssets, prefetchVectorForPlaces } from './glPrefetcher'
import { isNativeIos } from '../native/platform'
import { isIosDevice } from '../utils/iosDevice'
import { openCacheStorage } from './openCacheStorage'
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -48,6 +49,21 @@ const AVG_TILE_KB = 15
*/
export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288
/**
* The tile budget on an iPhone or iPad. WebKit reads the origin's Cache Storage
* before the service worker can answer the app's first request, and that read
* grows with the number of entries: a full cache of 12,288 tiles held a home
* screen app on a black screen for about 8 s on every cold start, about 0.7 ms
* an entry (#3008). 2,048 keeps that near a second and still covers the zooms a
* trip map opens at.
*/
export const IOS_MAX_TILES = 2048
/** How many tiles a prefetch may hold here: the iOS budget in Safari and its home screen apps. */
export function tileBudget(): number {
return isIosDevice() && !isNativeIos() ? IOS_MAX_TILES : MAX_TILES
}
/**
* Tile requests in flight at once.
*
@@ -224,7 +240,7 @@ export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): nu
for (let z = minZoom; z <= maxZoom; z++) {
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
total += (maxX - minX + 1) * (maxY - minY + 1)
if (total > MAX_TILES) return total
if (total > tileBudget()) return total
}
return total
}
@@ -252,7 +268,7 @@ export function buildTileUrl(template: string, z: number, x: number, y: number,
/**
* Enumerate the tile coordinates to prefetch, lowest zoom first, stopping at
* the zoom level whose tiles would push the total past MAX_TILES.
* the zoom level whose tiles would push the total past the budget (tileBudget).
*/
function enumerateTiles(
bbox: TileBbox,
@@ -260,11 +276,12 @@ function enumerateTiles(
maxZoom: number,
): Array<[z: number, x: number, y: number]> {
const coords: Array<[number, number, number]> = []
const budget = tileBudget()
for (let z = minZoom; z <= maxZoom; z++) {
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
if (coords.length + (maxX - minX + 1) * (maxY - minY + 1) > MAX_TILES) break
if (coords.length + (maxX - minX + 1) * (maxY - minY + 1) > budget) break
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) coords.push([z, x, y])
@@ -342,6 +359,49 @@ export async function prefetchTiles(
return fetched
}
const TRIM_KEY = 'trek_tile_trim_at'
const TRIM_EVERY_MS = 24 * 60 * 60 * 1000
/**
* Brings a tile cache on an iPhone or iPad back within IOS_MAX_TILES, oldest
* tiles first (Cache Storage lists keys in the order they were stored). The
* service worker's own limit is the same on every platform, so a cache filled
* before this budget, or by browsing, still grows past it there (#3008).
*
* Reading the keys of a large cache is itself slow on iOS, so this runs at most
* once a day and only when called after the app is up. Returns how many tiles
* it removed.
*/
export async function trimTileCacheForIos(now = Date.now()): Promise<number> {
if (!isIosDevice() || isNativeIos()) return 0
try {
const last = Number(localStorage.getItem(TRIM_KEY) || 0)
if (now - last < TRIM_EVERY_MS) return 0
localStorage.setItem(TRIM_KEY, String(now))
} catch {
return 0
}
try {
const cache = await openTileCache()
if (!cache) return 0
const keys = await cache.keys()
const surplus = keys.slice(0, Math.max(0, keys.length - IOS_MAX_TILES))
for (const request of surplus) await cache.delete(request)
return surplus.length
} catch {
// Cache Storage refused: the tiles stay as they are and tomorrow tries again.
return 0
}
}
/** Trims the iOS tile cache once the app has started, never in the way of its first screens. */
export function scheduleTileCacheTrim(delayMs = 15_000): void {
if (!isIosDevice() || isNativeIos()) return
window.setTimeout(() => {
void trimTileCacheForIos()
}, delayMs)
}
/**
* Drop the pre-downloaded map-tile cache. Called when the user turns off
* "store map tiles offline" (#1135 ask 2) so the bulk tile storage — the real
@@ -43,6 +43,8 @@ function enableCollectionsAddon(enabled: boolean) {
beforeEach(() => {
resetAllStores();
// The converter waits for the saved pair (#3009); these widgets render with settings in place.
useSettingsStore.setState({ isLoaded: true });
fxHandler();
collectionsHandler([]);
});
@@ -19,9 +19,13 @@ import {
UNPERSISTED_MAX_ZOOM,
clearTileCache,
MAX_TILES,
IOS_MAX_TILES,
TILE_CONCURRENCY,
tileBudget,
trimTileCacheForIos,
type TileBbox,
} from '../../../src/sync/tilePrefetcher';
import { isIosDevice } from '../../../src/utils/iosDevice';
import { offlineDb, clearAll, upsertSyncMeta } from '../../../src/db/offlineDb';
import { requestPersistentStorage, _resetPersistentStorage } from '../../../src/sync/persistentStorage';
import { setAuthed } from '../../../src/sync/authGate';
@@ -37,6 +41,10 @@ vi.mock('../../../src/sync/glPrefetcher', async importOriginal => ({
prefetchVectorForPlaces: vi.fn(async () => ({ tiles: 0, template: null })),
prefetchStyleAssets: vi.fn(async () => null),
}));
vi.mock('../../../src/utils/iosDevice', async importOriginal => ({
...(await importOriginal<typeof import('../../../src/utils/iosDevice')>()),
isIosDevice: vi.fn(() => false),
}));
vi.mock('../../../src/native/platform', async importOriginal => ({
...(await importOriginal<typeof import('../../../src/native/platform')>()),
isNativeIos: vi.fn(() => false),
@@ -47,6 +55,7 @@ beforeEach(async () => {
setAuthed(true);
// restoreAllMocks leaves a vi.fn's return value as a test set it; the iOS case below sets true.
vi.mocked(isNativeIos).mockReturnValue(false);
vi.mocked(isIosDevice).mockReturnValue(false);
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
// Stub fetch + serviceWorker so prefetch path is exercised
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
@@ -731,3 +740,50 @@ describe('prefetchTiles: a refused tile is not a cached tile (#2180)', () => {
expect(fetched).toBeLessThan(calls);
});
});
// ── iOS tile budget (#3008) ───────────────────────────────────────────────────
describe('the iOS tile budget', () => {
const tileKeys = (n: number) => Array.from({ length: n }, (_, i) => ({ url: `https://tile.example.com/${i}.png` }));
beforeEach(() => localStorage.clear());
it('holds a prefetch on an iPhone or iPad to IOS_MAX_TILES, and nowhere else', () => {
expect(tileBudget()).toBe(MAX_TILES);
vi.mocked(isIosDevice).mockReturnValue(true);
expect(tileBudget()).toBe(IOS_MAX_TILES);
// Inside the iOS app tiles never go through Cache Storage at all.
vi.mocked(isNativeIos).mockReturnValue(true);
expect(tileBudget()).toBe(MAX_TILES);
});
it('stops counting a large area at the iOS budget', () => {
vi.mocked(isIosDevice).mockReturnValue(true);
const europe: TileBbox = { minLat: 36, maxLat: 60, minLng: -10, maxLng: 30 };
const total = countTiles(europe, 0, 16);
expect(total).toBeGreaterThan(IOS_MAX_TILES);
expect(total).toBeLessThan(MAX_TILES);
});
it('trims a full cache down to the budget, oldest tiles first, once a day', async () => {
vi.mocked(isIosDevice).mockReturnValue(true);
const keys = tileKeys(IOS_MAX_TILES + 5);
const del = vi.fn().mockResolvedValue(true);
vi.stubGlobal('caches', { open: vi.fn().mockResolvedValue({ keys: vi.fn().mockResolvedValue(keys), delete: del }) });
const now = Date.UTC(2026, 10, 8, 12);
expect(await trimTileCacheForIos(now)).toBe(5);
expect(del.mock.calls.map(([k]) => k)).toEqual(keys.slice(0, 5));
// Reading a large cache's keys is slow on iOS: not again the same day.
expect(await trimTileCacheForIos(now + 60 * 60 * 1000)).toBe(0);
expect(del).toHaveBeenCalledTimes(5);
});
it('leaves the cache alone off iOS', async () => {
const open = vi.fn();
vi.stubGlobal('caches', { open });
expect(await trimTileCacheForIos()).toBe(0);
expect(open).not.toHaveBeenCalled();
});
});
@@ -54,6 +54,12 @@ export interface AiChatRequest {
cacheKey?: string;
/** Lets the model search the web with its provider's own search; a client without one ignores it. */
webSearch?: boolean;
/**
* `none` asks for text only: the tools are still sent, so the tool calls
* already in the history stay valid, but the model may not call one.
* Absent means the model chooses (#3016).
*/
toolChoice?: 'auto' | 'none';
}
/** A page the model's answer draws on, from its web search. */
@@ -168,6 +168,8 @@ export class AnthropicMessagesClient implements AiChatClient {
})),
...(request.webSearch ? [WEB_SEARCH_TOOL] : []),
],
// `none` is allowed with extended thinking, unlike forcing a tool.
...(request.toolChoice === 'none' ? { tool_choice: { type: 'none' } } : {}),
}
: {}),
};
@@ -143,7 +143,7 @@ export class OpenAiChatClient implements AiChatClient {
parameters: toProviderSchema(tool.parameters, this.schemaKind),
},
})),
tool_choice: 'auto',
tool_choice: request.toolChoice ?? 'auto',
}
: {}),
...(this.kind === 'openai' && request.cacheKey ? { prompt_cache_key: request.cacheKey } : {}),
@@ -111,7 +111,7 @@ function toolsOf(request: AiChatRequest, kind: AiConnectionKind): Record<string,
});
}
if (request.webSearch) tools.push({ type: 'web_search' });
return tools.length > 0 ? { tools, tool_choice: 'auto', parallel_tool_calls: true } : {};
return tools.length > 0 ? { tools, tool_choice: request.toolChoice ?? 'auto', parallel_tool_calls: true } : {};
}
/** The pages an output message cites, as its text's URL annotations name them. */
@@ -272,6 +272,12 @@ interface Turn {
* still gets its answer written (#2789); each is asked at most once.
*/
askedForAnswer: boolean;
/**
* The next request may only be answered with text: the step after the ask
* for the answer. Claude Sonnet 5.5 offered its follow-ups again instead of
* writing, and the turn ended without an answer (#3016).
*/
textOnlyNext: boolean;
/** The chips of follow-ups offered before any answer, shown under the answer once it is written. */
followUps: AssistantFollowUps | null;
}
@@ -360,6 +366,9 @@ function searchParts(
/** Streams one answer of the model; a failure or a stop ends the turn here, saved as far as it got. */
async function streamAnswer(turn: Turn, rowId: number): Promise<StepAnswer | AgentOutcome> {
const { deps, input, w, signal } = turn;
// Taken once: only the step right after the ask for the answer is held to text.
const textOnly = turn.textOnlyNext;
turn.textOnlyNext = false;
const answer: StepAnswer = {
text: '',
received: [],
@@ -377,6 +386,7 @@ async function streamAnswer(turn: Turn, rowId: number): Promise<StepAnswer | Age
tools: deps.tools.specs(),
cacheKey: input.cacheKey,
webSearch: deps.webSearch === true && deps.client.webSearch,
...(textOnly ? { toolChoice: 'none' as const } : {}),
},
signal,
);
@@ -535,6 +545,7 @@ function answeredAsk(call: AiToolCall): AssistantMessagePart {
async function askForTheAnswer(turn: Turn, rowId: number, step: StepRecord & { asks: AiToolCall[] }): Promise<null> {
const { asks, providerState } = step;
turn.askedForAnswer = true;
turn.textOnlyNext = true;
turn.followUps = followUpsOf(asks[asks.length - 1].args);
const parts = [...step.parts, ...asks.map((call) => ({ ...answeredAsk(call), result: WRITE_THE_ANSWER }))];
await turn.deps.store.saveStep(rowId, { parts, providerState }, 'complete');
@@ -673,6 +684,7 @@ export async function runAgentLoop(
knownPlaceIds,
nudged: false,
askedForAnswer: false,
textOnlyNext: false,
followUps: null,
};
return stepsFrom(turn, 0);
@@ -238,6 +238,23 @@ describe('AnthropicMessagesClient', () => {
expect([0, 1, 2, 3].map((i) => dig(body, 'messages', i, 'role'))).toEqual(['user', 'assistant', 'user', undefined]);
});
it('ANTH-009: a text-only request keeps the tools for the history and sets tool_choice none (#3016)', async () => {
const http = fetchAnswering(streamResponse(STREAM));
await run(
new AnthropicMessagesClient('claude-sonnet', 'sk-ant-key', http.fetcher).stream(
{ ...REQUEST, toolChoice: 'none' },
signal(),
),
);
const body = http.body();
expect(body.tool_choice).toEqual({ type: 'none' });
expect(dig(body, 'tools', 0, 'name')).toBe('create_place');
const plain = fetchAnswering(streamResponse(STREAM));
await run(new AnthropicMessagesClient('claude-sonnet', 'sk-ant-key', plain.fetcher).stream(REQUEST, signal()));
expect(plain.body().tool_choice).toBeUndefined();
});
it('ANTH-006: marks the end of the history and the end of what came before the question for the cache', async () => {
const http = fetchAnswering(streamResponse(STREAM));
await run(new AnthropicMessagesClient('m', 'k', http.fetcher).stream(REQUEST, signal()));
@@ -458,6 +475,17 @@ describe('OpenAiChatClient', () => {
]);
});
it('OACH-009: tool_choice is auto unless the request asks for text only (#3016)', async () => {
const client = (fetcher: AiFetch) =>
new OpenAiChatClient('openai', 'gpt-5-mini', 'https://api.openai.com/v1', 'sk-key', fetcher);
const auto = fetchAnswering(streamResponse(STREAM));
await run(client(auto.fetcher).stream(REQUEST, signal()));
expect(auto.body().tool_choice).toBe('auto');
const none = fetchAnswering(streamResponse(STREAM));
await run(client(none.fetcher).stream({ ...REQUEST, toolChoice: 'none' }, signal()));
expect(none.body().tool_choice).toBe('none');
});
it('OACH-002: streams the text and assembles the tool call by its index', async () => {
const events = await run(
new OpenAiChatClient('openai', 'm', 'https://x', 'k', fetchAnswering(streamResponse(STREAM)).fetcher).stream(
@@ -689,6 +717,20 @@ describe('OpenAiResponsesClient', () => {
expect(dig(body, 'input', 0)).toEqual({ type: 'message', role: 'developer', content: 'Trip: Japan, 3 days' });
});
it('OARS-006: tool_choice is auto unless the request asks for text only (#3016)', async () => {
const auto = fetchAnswering(streamResponse(STREAM));
await run(new OpenAiResponsesClient('gpt-6.1-sol', tokens('at-1'), auto.fetcher).stream(REQUEST, signal()));
expect(auto.body().tool_choice).toBe('auto');
const none = fetchAnswering(streamResponse(STREAM));
await run(
new OpenAiResponsesClient('gpt-6.1-sol', tokens('at-1'), none.fetcher).stream(
{ ...REQUEST, toolChoice: 'none' },
signal(),
),
);
expect(none.body().tool_choice).toBe('none');
});
it('OARS-002: streams text, yields the call, then usage, the items to replay and the finish', async () => {
const events = await run(
new OpenAiResponsesClient('m', tokens('at'), fetchAnswering(streamResponse(STREAM)).fetcher).stream(
@@ -830,6 +830,10 @@ describe('runAgentLoop', () => {
]);
const outcome = await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal);
expect(outcome.end).toBe('stop');
// Only the step after the ask may not call a tool: a model offering follow-ups again has to write (#3016).
expect(t.requests[0].toolChoice).toBeUndefined();
expect(t.requests[1].toolChoice).toBe('none');
expect(t.requests[1].tools.length).toBeGreaterThan(0);
const second = t.requests[1].messages;
expect(second.at(-2)).toEqual({
role: 'assistant',
@@ -860,6 +864,20 @@ describe('runAgentLoop', () => {
expect(t.requests).toHaveLength(2);
});
it('ASTAGT-047: the text-only request is the one step after the ask, never the ones before or after it (#3016)', async () => {
const t = setup([
[{ type: 'tool-call', call: { id: 'l1', name: 'list_places', args: {} } }],
[ask('toolu_9', ['More stops']), { type: 'finish', reason: 'tool-calls' }],
[
{ type: 'text-delta', delta: 'Two stops so far.' },
{ type: 'finish', reason: 'stop' },
],
]);
const outcome = await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal);
expect(outcome.end).toBe('stop');
expect(t.requests.map((request) => request.toolChoice)).toEqual([undefined, undefined, 'none']);
});
it('ASTAGT-045: follow-ups with no answer after an announcing first answer are still asked for the answer', async () => {
const t = setup([
[{ type: 'text-delta', delta: 'Let me check your places:' }],
+11
View File
@@ -130,6 +130,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'أصوات',
'native.widget.noMessages': 'لا توجد رسائل بعد',
'native.widget.departure': 'المغادرة',
'native.gate.title': '{name} يحمي هذا الخادم',
'native.gate.titleUnknown': 'صفحة تسجيل دخول تحمي هذا الخادم',
'native.gate.titleBasic': 'طلب كلمة مرور يحمي هذا الخادم',
'native.gate.hint': 'سجّل الدخول هناك مرة واحدة داخل التطبيق، كما في المتصفح. يحتفظ التطبيق بتلك الجلسة.',
'native.gate.signIn': 'تسجيل الدخول إلى {name}',
'native.gate.signInGeneric': 'تسجيل الدخول',
'native.gate.googleNote':
'لا يعمل تسجيل الدخول عبر Google داخل التطبيقات. إن كان تسجيلك يستخدمه، فاسمح لـ TREK بتجاوز الوكيل بدلًا من ذلك.',
'native.gate.basicHint':
'لا يستطيع التطبيق الرد على هذا الطلب. اسمح لـ TREK بتجاوزه في الوكيل: سيظل TREK يطلب تسجيل الدخول الخاص به.',
'native.gate.help': 'استخدام التطبيق خلف وكيل تسجيل دخول',
};
export default native;
+11
View File
@@ -134,6 +134,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'səs',
'native.widget.noMessages': 'Hələ mesaj yoxdur',
'native.widget.departure': 'Yola düşmə',
'native.gate.title': '{name} bu serveri qoruyur',
'native.gate.titleUnknown': 'Bu serveri giriş səhifəsi qoruyur',
'native.gate.titleBasic': 'Bu serveri parol sorğusu qoruyur',
'native.gate.hint': 'Orada bir dəfə tətbiqin içində, brauzerdə olduğu kimi daxil olun. Tətbiq bu sessiyanı saxlayır.',
'native.gate.signIn': '{name} hesabına daxil olun',
'native.gate.signInGeneric': 'Daxil olun',
'native.gate.googleNote':
'Google ilə giriş tətbiqin içində işləmir. Sizinki ondan istifadə edirsə, TREK-i proksidən keçirin.',
'native.gate.basicHint':
'Tətbiq bu sorğuya cavab verə bilmir. TREK-i proksinizdə keçirin: TREK yenə öz girişini istəyir.',
'native.gate.help': 'Tətbiqi giriş proksisinin arxasında istifadə etmək',
};
export default native;
+11
View File
@@ -133,6 +133,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'votos',
'native.widget.noMessages': 'Nenhuma mensagem ainda',
'native.widget.departure': 'Partida',
'native.gate.title': '{name} protege este servidor',
'native.gate.titleUnknown': 'Uma página de login protege este servidor',
'native.gate.titleBasic': 'Um pedido de senha protege este servidor',
'native.gate.hint': 'Entre lá uma vez dentro do app, como no navegador. O app mantém essa sessão.',
'native.gate.signIn': 'Entrar no {name}',
'native.gate.signInGeneric': 'Entrar',
'native.gate.googleNote':
'Um login com o Google não funciona dentro de um app. Se o seu usa, deixe o TREK passar pelo proxy.',
'native.gate.basicHint':
'O app não consegue responder a este pedido. Deixe o TREK passar no seu proxy: o TREK continua pedindo o próprio login.',
'native.gate.help': 'Usar o app atrás de um proxy de login',
};
export default native;
+11
View File
@@ -134,6 +134,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'vots',
'native.widget.noMessages': 'Encara no hi ha missatges',
'native.widget.departure': 'Sortida',
'native.gate.title': '{name} protegeix aquest servidor',
'native.gate.titleUnknown': "Una pàgina d'inici de sessió protegeix aquest servidor",
'native.gate.titleBasic': 'Una sol·licitud de contrasenya protegeix aquest servidor',
'native.gate.hint': "Inicia la sessió allà un cop dins l'app, com en un navegador. L'app conserva aquesta sessió.",
'native.gate.signIn': 'Inicia la sessió a {name}',
'native.gate.signInGeneric': 'Inicia la sessió',
'native.gate.googleNote':
"Un inici de sessió amb Google no funciona dins d'una app. Si el teu el fa servir, deixa passar TREK pel proxy.",
'native.gate.basicHint':
"L'app no pot respondre aquesta sol·licitud. Deixa passar TREK al teu proxy: TREK continua demanant el seu propi inici de sessió.",
'native.gate.help': "Fer servir l'app darrere d'un proxy d'inici de sessió",
};
export default native;
+11
View File
@@ -132,6 +132,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'hlasů',
'native.widget.noMessages': 'Zatím žádné zprávy',
'native.widget.departure': 'Odjezd',
'native.gate.title': '{name} chrání tento server',
'native.gate.titleUnknown': 'Tento server chrání přihlašovací stránka',
'native.gate.titleBasic': 'Tento server chrání výzva k zadání hesla',
'native.gate.hint': 'Přihlaste se tam jednou v aplikaci, jako v prohlížeči. Aplikace si relaci ponechá.',
'native.gate.signIn': 'Přihlásit se k {name}',
'native.gate.signInGeneric': 'Přihlásit se',
'native.gate.googleNote':
'Přihlášení přes Google v aplikaci nefunguje. Pokud ho používáte, nechte TREK projít přes proxy.',
'native.gate.basicHint':
'Aplikace na tuto výzvu nedokáže odpovědět. Nechte TREK ve své proxy projít: TREK stále žádá své vlastní přihlášení.',
'native.gate.help': 'Používání aplikace za přihlašovací proxy',
};
export default native;
+11
View File
@@ -135,6 +135,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'Stimmen',
'native.widget.noMessages': 'Noch keine Nachrichten',
'native.widget.departure': 'Abreise',
'native.gate.title': '{name} schützt diesen Server',
'native.gate.titleUnknown': 'Eine Anmeldeseite schützt diesen Server',
'native.gate.titleBasic': 'Eine Passwortabfrage schützt diesen Server',
'native.gate.hint': 'Melde dich dort einmal in der App an, wie im Browser. Die App behält diese Sitzung.',
'native.gate.signIn': 'Bei {name} anmelden',
'native.gate.signInGeneric': 'Anmelden',
'native.gate.googleNote':
'Eine Anmeldung mit Google funktioniert nicht in einer App. Nutzt deine sie, lass TREK stattdessen am Proxy vorbei.',
'native.gate.basicHint':
'Die App kann diese Abfrage nicht beantworten. Lass TREK in deinem Proxy daran vorbei: TREK fragt weiterhin nach seiner eigenen Anmeldung.',
'native.gate.help': 'Die App hinter einem Anmelde-Proxy nutzen',
};
export default native;
+11
View File
@@ -133,6 +133,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'votes',
'native.widget.noMessages': 'No messages yet',
'native.widget.departure': 'Departure',
'native.gate.title': '{name} protects this server',
'native.gate.titleUnknown': 'A sign-in page protects this server',
'native.gate.titleBasic': 'A password prompt protects this server',
'native.gate.hint': 'Sign in there once inside the app, as you would in a browser. The app keeps that session.',
'native.gate.signIn': 'Sign in to {name}',
'native.gate.signInGeneric': 'Sign in',
'native.gate.googleNote':
'A sign-in with Google cannot run inside an app. If yours uses it, let TREK through the proxy instead.',
'native.gate.basicHint':
'The app cannot answer this prompt. Let TREK through it in your proxy: TREK still asks for its own login.',
'native.gate.help': 'Using the app behind a sign-in proxy',
};
export default native;
+11
View File
@@ -135,6 +135,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'votos',
'native.widget.noMessages': 'Aún no hay mensajes',
'native.widget.departure': 'Salida',
'native.gate.title': '{name} protege este servidor',
'native.gate.titleUnknown': 'Una página de inicio de sesión protege este servidor',
'native.gate.titleBasic': 'Una solicitud de contraseña protege este servidor',
'native.gate.hint': 'Inicia sesión allí una vez dentro de la app, como en un navegador. La app conserva esa sesión.',
'native.gate.signIn': 'Iniciar sesión en {name}',
'native.gate.signInGeneric': 'Iniciar sesión',
'native.gate.googleNote':
'Un inicio de sesión con Google no funciona dentro de una app. Si el tuyo lo usa, deja pasar TREK por el proxy.',
'native.gate.basicHint':
'La app no puede responder a esta solicitud. Deja pasar TREK en tu proxy: TREK sigue pidiendo su propio inicio de sesión.',
'native.gate.help': 'Usar la app detrás de un proxy de inicio de sesión',
};
export default native;
+11
View File
@@ -133,6 +133,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'häält',
'native.widget.noMessages': 'Sõnumeid veel pole',
'native.widget.departure': 'Väljasõit',
'native.gate.title': '{name} kaitseb seda serverit',
'native.gate.titleUnknown': 'Seda serverit kaitseb sisselogimisleht',
'native.gate.titleBasic': 'Seda serverit kaitseb paroolipäring',
'native.gate.hint': 'Logi seal üks kord rakenduses sisse, nagu brauseris. Rakendus jätab selle seansi meelde.',
'native.gate.signIn': 'Logi sisse: {name}',
'native.gate.signInGeneric': 'Logi sisse',
'native.gate.googleNote':
'Google kaudu sisselogimine rakenduses ei tööta. Kui sinu oma seda kasutab, lase TREK proksist hoopis läbi.',
'native.gate.basicHint':
'Rakendus ei saa sellele päringule vastata. Lase TREK oma proksis läbi: TREK küsib endiselt oma sisselogimist.',
'native.gate.help': 'Rakenduse kasutamine sisselogimisproksi taga',
};
export default native;
+11
View File
@@ -135,6 +135,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'voix',
'native.widget.noMessages': 'Pas encore de messages',
'native.widget.departure': 'Départ',
'native.gate.title': '{name} protège ce serveur',
'native.gate.titleUnknown': 'Une page de connexion protège ce serveur',
'native.gate.titleBasic': 'Une demande de mot de passe protège ce serveur',
'native.gate.hint': "Connectez-vous-y une fois dans l'app, comme dans un navigateur. L'app garde cette session.",
'native.gate.signIn': 'Se connecter à {name}',
'native.gate.signInGeneric': 'Se connecter',
'native.gate.googleNote':
"Une connexion avec Google ne fonctionne pas dans une app. Si la vôtre l'utilise, laissez plutôt passer TREK dans le proxy.",
'native.gate.basicHint':
"L'app ne peut pas répondre à cette demande. Laissez passer TREK dans votre proxy : TREK demande toujours sa propre connexion.",
'native.gate.help': "Utiliser l'app derrière un proxy de connexion",
};
export default native;
+12
View File
@@ -136,6 +136,18 @@ const native: TranslationStrings = {
'native.widget.votes': 'ψήφοι',
'native.widget.noMessages': 'Δεν υπάρχουν ακόμα μηνύματα',
'native.widget.departure': 'Αναχώρηση',
'native.gate.title': 'Το {name} προστατεύει αυτόν τον διακομιστή',
'native.gate.titleUnknown': 'Μια σελίδα σύνδεσης προστατεύει αυτόν τον διακομιστή',
'native.gate.titleBasic': 'Ένα αίτημα κωδικού προστατεύει αυτόν τον διακομιστή',
'native.gate.hint':
'Συνδεθείτε εκεί μία φορά μέσα στην εφαρμογή, όπως σε ένα πρόγραμμα περιήγησης. Η εφαρμογή κρατά αυτή τη συνεδρία.',
'native.gate.signIn': 'Σύνδεση στο {name}',
'native.gate.signInGeneric': 'Σύνδεση',
'native.gate.googleNote':
'Η σύνδεση με Google δεν λειτουργεί μέσα σε εφαρμογή. Αν τη χρησιμοποιεί ο δικός σας, αφήστε το TREK να περνά από τον proxy.',
'native.gate.basicHint':
'Η εφαρμογή δεν μπορεί να απαντήσει σε αυτό το αίτημα. Αφήστε το TREK να περνά στον proxy σας: το TREK ζητά ακόμη τη δική του σύνδεση.',
'native.gate.help': 'Χρήση της εφαρμογής πίσω από proxy σύνδεσης',
};
export default native;
+11
View File
@@ -130,6 +130,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'קולות',
'native.widget.noMessages': 'אין עדיין הודעות',
'native.widget.departure': 'יציאה',
'native.gate.title': '{name} מגן על השרת הזה',
'native.gate.titleUnknown': 'דף התחברות מגן על השרת הזה',
'native.gate.titleBasic': 'בקשת סיסמה מגינה על השרת הזה',
'native.gate.hint': 'התחבר שם פעם אחת בתוך האפליקציה, כמו בדפדפן. האפליקציה שומרת את ההתחברות.',
'native.gate.signIn': 'התחברות ל{name}',
'native.gate.signInGeneric': 'התחברות',
'native.gate.googleNote':
'התחברות עם Google לא עובדת בתוך אפליקציה. אם שלך משתמש בה, אפשר ל TREK לעבור את הפרוקסי במקום זאת.',
'native.gate.basicHint':
'האפליקציה לא יכולה לענות לבקשה הזו. אפשר ל TREK לעבור אותה בפרוקסי שלך: TREK עדיין מבקש התחברות משלו.',
'native.gate.help': 'שימוש באפליקציה מאחורי פרוקסי התחברות',
};
export default native;
+12
View File
@@ -135,6 +135,18 @@ const native: TranslationStrings = {
'native.widget.votes': 'szavazat',
'native.widget.noMessages': 'Még nincs üzenet',
'native.widget.departure': 'Indulás',
'native.gate.title': 'A(z) {name} védi ezt a szervert',
'native.gate.titleUnknown': 'Egy bejelentkezési oldal védi ezt a szervert',
'native.gate.titleBasic': 'Egy jelszókérés védi ezt a szervert',
'native.gate.hint':
'Jelentkezz be ott egyszer az alkalmazásban, mint egy böngészőben. Az alkalmazás megőrzi ezt a munkamenetet.',
'native.gate.signIn': 'Bejelentkezés: {name}',
'native.gate.signInGeneric': 'Bejelentkezés',
'native.gate.googleNote':
'A Google-bejelentkezés nem működik alkalmazáson belül. Ha a tiéd ezt használja, inkább engedd át a TREK-et a proxyn.',
'native.gate.basicHint':
'Az alkalmazás nem tud válaszolni erre a kérésre. Engedd át a TREK-et a proxyban: a TREK továbbra is a saját bejelentkezését kéri.',
'native.gate.help': 'Az alkalmazás használata bejelentkezési proxy mögött',
};
export default native;
+11
View File
@@ -134,6 +134,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'suara',
'native.widget.noMessages': 'Belum ada pesan',
'native.widget.departure': 'Keberangkatan',
'native.gate.title': '{name} melindungi server ini',
'native.gate.titleUnknown': 'Halaman masuk melindungi server ini',
'native.gate.titleBasic': 'Permintaan kata sandi melindungi server ini',
'native.gate.hint': 'Masuk di sana sekali di dalam aplikasi, seperti di browser. Aplikasi menyimpan sesi itu.',
'native.gate.signIn': 'Masuk ke {name}',
'native.gate.signInGeneric': 'Masuk',
'native.gate.googleNote':
'Masuk dengan Google tidak bisa berjalan di dalam aplikasi. Jika milikmu memakainya, loloskan TREK di proxy saja.',
'native.gate.basicHint':
'Aplikasi tidak bisa menjawab permintaan ini. Loloskan TREK di proxy kamu: TREK tetap meminta login miliknya sendiri.',
'native.gate.help': 'Memakai aplikasi di balik proxy masuk',
};
export default native;
+11
View File
@@ -135,6 +135,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'voti',
'native.widget.noMessages': 'Ancora nessun messaggio',
'native.widget.departure': 'Partenza',
'native.gate.title': '{name} protegge questo server',
'native.gate.titleUnknown': 'Una pagina di accesso protegge questo server',
'native.gate.titleBasic': 'Una richiesta di password protegge questo server',
'native.gate.hint': "Accedi lì una volta dentro l'app, come in un browser. L'app mantiene quella sessione.",
'native.gate.signIn': 'Accedi a {name}',
'native.gate.signInGeneric': 'Accedi',
'native.gate.googleNote':
"Un accesso con Google non funziona dentro un'app. Se il tuo lo usa, lascia passare TREK nel proxy.",
'native.gate.basicHint':
"L'app non può rispondere a questa richiesta. Lascia passare TREK nel tuo proxy: TREK chiede comunque il proprio accesso.",
'native.gate.help': "Usare l'app dietro un proxy di accesso",
};
export default native;
+12
View File
@@ -134,6 +134,18 @@ const native: TranslationStrings = {
'native.widget.votes': '票',
'native.widget.noMessages': 'まだメッセージはありません',
'native.widget.departure': '出発',
'native.gate.title': '{name} がこのサーバーを保護しています',
'native.gate.titleUnknown': 'サインインページがこのサーバーを保護しています',
'native.gate.titleBasic': 'パスワード入力がこのサーバーを保護しています',
'native.gate.hint':
'ブラウザと同じように、アプリ内で一度サインインしてください。アプリがそのセッションを保持します。',
'native.gate.signIn': '{name} にサインイン',
'native.gate.signInGeneric': 'サインイン',
'native.gate.googleNote':
'Google でのサインインはアプリ内では動作しません。お使いのものが Google を使う場合は、プロキシで TREK を通過させてください。',
'native.gate.basicHint':
'アプリはこの入力に応答できません。プロキシで TREK を通過させてください。TREK は引き続き独自のログインを求めます。',
'native.gate.help': 'サインインプロキシの背後でアプリを使う',
};
export default native;
+11
View File
@@ -131,6 +131,17 @@ const native: TranslationStrings = {
'native.widget.votes': '표',
'native.widget.noMessages': '아직 메시지가 없습니다',
'native.widget.departure': '출발',
'native.gate.title': '{name}이(가) 이 서버를 보호합니다',
'native.gate.titleUnknown': '로그인 페이지가 이 서버를 보호합니다',
'native.gate.titleBasic': '비밀번호 요청이 이 서버를 보호합니다',
'native.gate.hint': '브라우저처럼 앱 안에서 한 번 로그인하세요. 앱이 그 세션을 유지합니다.',
'native.gate.signIn': '{name}에 로그인',
'native.gate.signInGeneric': '로그인',
'native.gate.googleNote':
'Google 로그인은 앱 안에서 작동하지 않습니다. 사용 중이라면 대신 프록시에서 TREK을 통과시키세요.',
'native.gate.basicHint':
'앱은 이 요청에 응답할 수 없습니다. 프록시에서 TREK을 통과시키세요. TREK은 여전히 자체 로그인을 요청합니다.',
'native.gate.help': '로그인 프록시 뒤에서 앱 사용하기',
};
export default native;
+11
View File
@@ -133,6 +133,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'stemmen',
'native.widget.noMessages': 'Nog geen berichten',
'native.widget.departure': 'Vertrek',
'native.gate.title': '{name} beschermt deze server',
'native.gate.titleUnknown': 'Een aanmeldpagina beschermt deze server',
'native.gate.titleBasic': 'Een wachtwoordvraag beschermt deze server',
'native.gate.hint': 'Meld je daar één keer aan in de app, zoals in een browser. De app onthoudt die sessie.',
'native.gate.signIn': 'Aanmelden bij {name}',
'native.gate.signInGeneric': 'Aanmelden',
'native.gate.googleNote':
'Aanmelden met Google werkt niet in een app. Gebruikt de jouwe dat, laat TREK dan langs de proxy.',
'native.gate.basicHint':
'De app kan deze vraag niet beantwoorden. Laat TREK er in je proxy langs: TREK vraagt nog steeds om zijn eigen aanmelding.',
'native.gate.help': 'De app achter een aanmeldproxy gebruiken',
};
export default native;
+11
View File
@@ -133,6 +133,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'głosów',
'native.widget.noMessages': 'Brak wiadomości',
'native.widget.departure': 'Wyjazd',
'native.gate.title': '{name} chroni ten serwer',
'native.gate.titleUnknown': 'Strona logowania chroni ten serwer',
'native.gate.titleBasic': 'Prośba o hasło chroni ten serwer',
'native.gate.hint': 'Zaloguj się tam raz w aplikacji, jak w przeglądarce. Aplikacja zachowa tę sesję.',
'native.gate.signIn': 'Zaloguj się do {name}',
'native.gate.signInGeneric': 'Zaloguj się',
'native.gate.googleNote':
'Logowanie przez Google nie działa w aplikacji. Jeśli Twoje go używa, przepuść TREK przez proxy.',
'native.gate.basicHint':
'Aplikacja nie może odpowiedzieć na tę prośbę. Przepuść TREK w swoim proxy: TREK nadal pyta o własne logowanie.',
'native.gate.help': 'Korzystanie z aplikacji za proxy logowania',
};
export default native;
+11
View File
@@ -134,6 +134,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'голосов',
'native.widget.noMessages': 'Сообщений пока нет',
'native.widget.departure': 'Отъезд',
'native.gate.title': '{name} защищает этот сервер',
'native.gate.titleUnknown': 'Этот сервер защищает страница входа',
'native.gate.titleBasic': 'Этот сервер защищает запрос пароля',
'native.gate.hint': 'Войдите там один раз прямо в приложении, как в браузере. Приложение сохранит этот сеанс.',
'native.gate.signIn': 'Войти в {name}',
'native.gate.signInGeneric': 'Войти',
'native.gate.googleNote':
'Вход через Google не работает внутри приложения. Если ваш вход использует его, пропустите TREK через прокси.',
'native.gate.basicHint':
'Приложение не может ответить на этот запрос. Пропустите TREK в вашем прокси: TREK по-прежнему просит собственный вход.',
'native.gate.help': 'Приложение за прокси со входом',
};
export default native;
+11
View File
@@ -132,6 +132,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'hlasov',
'native.widget.noMessages': 'Zatiaľ žiadne správy',
'native.widget.departure': 'Odchod',
'native.gate.title': '{name} chráni tento server',
'native.gate.titleUnknown': 'Tento server chráni prihlasovacia stránka',
'native.gate.titleBasic': 'Tento server chráni výzva na zadanie hesla',
'native.gate.hint': 'Prihláste sa tam raz v aplikácii, ako v prehliadači. Aplikácia si reláciu ponechá.',
'native.gate.signIn': 'Prihlásiť sa do {name}',
'native.gate.signInGeneric': 'Prihlásiť sa',
'native.gate.googleNote':
'Prihlásenie cez Google v aplikácii nefunguje. Ak ho používate, nechajte TREK prejsť cez proxy.',
'native.gate.basicHint':
'Aplikácia nedokáže na túto výzvu odpovedať. Nechajte TREK vo svojej proxy prejsť: TREK stále žiada vlastné prihlásenie.',
'native.gate.help': 'Používanie aplikácie za prihlasovacou proxy',
};
export default native;
+11
View File
@@ -133,6 +133,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'röster',
'native.widget.noMessages': 'Inga meddelanden än',
'native.widget.departure': 'Avresa',
'native.gate.title': '{name} skyddar den här servern',
'native.gate.titleUnknown': 'En inloggningssida skyddar den här servern',
'native.gate.titleBasic': 'En lösenordsfråga skyddar den här servern',
'native.gate.hint': 'Logga in där en gång i appen, som i en webbläsare. Appen behåller sessionen.',
'native.gate.signIn': 'Logga in på {name}',
'native.gate.signInGeneric': 'Logga in',
'native.gate.googleNote':
'Inloggning med Google fungerar inte i en app. Om din använder det, släpp i stället förbi TREK i proxyn.',
'native.gate.basicHint':
'Appen kan inte svara på den här frågan. Släpp förbi TREK i din proxy: TREK frågar fortfarande efter sin egen inloggning.',
'native.gate.help': 'Använda appen bakom en inloggningsproxy',
};
export default native;
+10
View File
@@ -130,6 +130,16 @@ const native: TranslationStrings = {
'native.widget.votes': 'เสียง',
'native.widget.noMessages': 'ยังไม่มีข้อความ',
'native.widget.departure': 'ออกเดินทาง',
'native.gate.title': '{name} ปกป้องเซิร์ฟเวอร์นี้',
'native.gate.titleUnknown': 'หน้าลงชื่อเข้าใช้ปกป้องเซิร์ฟเวอร์นี้',
'native.gate.titleBasic': 'คำขอรหัสผ่านปกป้องเซิร์ฟเวอร์นี้',
'native.gate.hint': 'ลงชื่อเข้าใช้ที่นั่นหนึ่งครั้งในแอป เหมือนในเบราว์เซอร์ แอปจะเก็บเซสชันนั้นไว้',
'native.gate.signIn': 'ลงชื่อเข้าใช้ {name}',
'native.gate.signInGeneric': 'ลงชื่อเข้าใช้',
'native.gate.googleNote': 'การลงชื่อเข้าใช้ด้วย Google ใช้ในแอปไม่ได้ หากของคุณใช้ ให้ปล่อย TREK ผ่านพร็อกซีแทน',
'native.gate.basicHint':
'แอปตอบคำขอนี้ไม่ได้ ให้ปล่อย TREK ผ่านในพร็อกซีของคุณ: TREK ยังคงขอการลงชื่อเข้าใช้ของตัวเอง',
'native.gate.help': 'การใช้แอปหลังพร็อกซีลงชื่อเข้าใช้',
};
export default native;
+11
View File
@@ -134,6 +134,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'oy',
'native.widget.noMessages': 'Henüz mesaj yok',
'native.widget.departure': 'Hareket',
'native.gate.title': '{name} bu sunucuyu koruyor',
'native.gate.titleUnknown': 'Bu sunucuyu bir giriş sayfası koruyor',
'native.gate.titleBasic': 'Bu sunucuyu bir parola isteği koruyor',
'native.gate.hint': 'Orada bir kez uygulamanın içinde, tarayıcıdaki gibi giriş yapın. Uygulama bu oturumu saklar.',
'native.gate.signIn': '{name} ile giriş yap',
'native.gate.signInGeneric': 'Giriş yap',
'native.gate.googleNote':
'Google ile giriş bir uygulamanın içinde çalışmaz. Sizinki bunu kullanıyorsa TREK’i proxy’den geçirin.',
'native.gate.basicHint':
'Uygulama bu isteğe yanıt veremez. TREK’i proxy’nizde geçirin: TREK yine kendi girişini ister.',
'native.gate.help': 'Uygulamayı bir giriş proxy’sinin arkasında kullanma',
};
export default native;
+11
View File
@@ -133,6 +133,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'голосів',
'native.widget.noMessages': 'Повідомлень поки немає',
'native.widget.departure': 'Відʼїзд',
'native.gate.title': '{name} захищає цей сервер',
'native.gate.titleUnknown': 'Цей сервер захищає сторінка входу',
'native.gate.titleBasic': 'Цей сервер захищає запит пароля',
'native.gate.hint': 'Увійдіть там один раз просто в застосунку, як у браузері. Застосунок збереже цей сеанс.',
'native.gate.signIn': 'Увійти в {name}',
'native.gate.signInGeneric': 'Увійти',
'native.gate.googleNote':
'Вхід через Google не працює в застосунку. Якщо ваш вхід його використовує, пропустіть TREK через проксі.',
'native.gate.basicHint':
'Застосунок не може відповісти на цей запит. Пропустіть TREK у вашому проксі: TREK і далі просить власний вхід.',
'native.gate.help': 'Застосунок за проксі з входом',
};
export default native;
+11
View File
@@ -135,6 +135,17 @@ const native: TranslationStrings = {
'native.widget.votes': 'phiếu',
'native.widget.noMessages': 'Chưa có tin nhắn',
'native.widget.departure': 'Khởi hành',
'native.gate.title': '{name} bảo vệ máy chủ này',
'native.gate.titleUnknown': 'Một trang đăng nhập bảo vệ máy chủ này',
'native.gate.titleBasic': 'Một yêu cầu mật khẩu bảo vệ máy chủ này',
'native.gate.hint': 'Đăng nhập ở đó một lần ngay trong ứng dụng, như trên trình duyệt. Ứng dụng giữ phiên đó.',
'native.gate.signIn': 'Đăng nhập vào {name}',
'native.gate.signInGeneric': 'Đăng nhập',
'native.gate.googleNote':
'Đăng nhập bằng Google không chạy được trong ứng dụng. Nếu của bạn dùng nó, hãy cho TREK đi qua proxy.',
'native.gate.basicHint':
'Ứng dụng không thể trả lời yêu cầu này. Hãy cho TREK đi qua proxy của bạn: TREK vẫn hỏi đăng nhập riêng của nó.',
'native.gate.help': 'Dùng ứng dụng sau một proxy đăng nhập',
};
export default native;
+9
View File
@@ -129,6 +129,15 @@ const native: TranslationStrings = {
'native.widget.votes': '票',
'native.widget.noMessages': '還沒有訊息',
'native.widget.departure': '出發',
'native.gate.title': '{name} 保護著此伺服器',
'native.gate.titleUnknown': '一個登入頁面保護著此伺服器',
'native.gate.titleBasic': '一個密碼提示保護著此伺服器',
'native.gate.hint': '在 App 內像在瀏覽器中一樣登入一次。App 會保留該工作階段。',
'native.gate.signIn': '登入 {name}',
'native.gate.signInGeneric': '登入',
'native.gate.googleNote': '使用 Google 登入無法在 App 內進行。如果你的使用了它,請改為讓 TREK 繞過代理。',
'native.gate.basicHint': 'App 無法回應此提示。請在你的代理中放行 TREK:TREK 仍會要求它自己的登入。',
'native.gate.help': '在登入代理後使用 App',
};
export default native;
+9
View File
@@ -129,6 +129,15 @@ const native: TranslationStrings = {
'native.widget.votes': '票',
'native.widget.noMessages': '还没有消息',
'native.widget.departure': '出发',
'native.gate.title': '{name} 保护着此服务器',
'native.gate.titleUnknown': '一个登录页面保护着此服务器',
'native.gate.titleBasic': '一个密码提示保护着此服务器',
'native.gate.hint': '在应用内像在浏览器中一样登录一次。应用会保留该会话。',
'native.gate.signIn': '登录 {name}',
'native.gate.signInGeneric': '登录',
'native.gate.googleNote': '使用 Google 登录无法在应用内进行。如果你的使用了它,请改为让 TREK 绕过代理。',
'native.gate.basicHint': '应用无法回应此提示。请在你的代理中放行 TREK:TREK 仍会要求它自己的登录。',
'native.gate.help': '在登录代理后使用应用',
};
export default native;
+15
View File
@@ -13,6 +13,7 @@ The app checks that the address answers as a TREK server that knows the apps, an
- **No server answered**: the address is wrong, the server is down, or the phone cannot reach it (see the network section below).
- **Not TREK**: something answered, but not a TREK server.
- **Too old**: a TREK server from before the apps. Update it to a current release.
- **A sign-in page protects this server**: a proxy of your own stands in front of TREK. See the next section.
No server yet? **Try the demo** connects to the public demo at demo.liketrek.com, which everybody shares and which is reset every hour. **What is a TREK server?** leads to the project page, which explains how to run your own.
@@ -30,6 +31,20 @@ Passkeys are managed in the browser for the same reason: the **Passkeys** card i
The app keeps the trips you choose offline the same way the browser does, including their maps; see [Offline-Mode-and-PWA](Offline-Mode-and-PWA). On iOS, where the web view runs no service worker, the app stores a copy of the server's start page and of every script of its current version, so it also starts with no network. When the server cannot be reached on start, the app opens that copy, or offers **Try again** and **Change server**.
## Behind a sign-in proxy
Many servers sit behind a proxy that asks for its own sign-in before anything reaches TREK: Cloudflare Access, Pangolin, authentik, Authelia, OAuth2 Proxy, or a password prompt (Basic Auth) in nginx, Caddy or Traefik. The app recognises these when you enter the address and names the one it found.
**Sign in inside the app.** For a proxy with a sign-in page, the address screen offers **Sign in to …**. The app then opens your server, and the proxy's sign-in page shows inside the app, as it would in a browser. Sign in there once; the proxy's session stays with the app and every request carries it, so TREK loads behind it. Only the sign-in host the app saw while checking the address may open inside the app; every other site still opens in the system browser, and the proxy's page has no access to the app. When the proxy's session runs out, its sign-in page shows again on the next start.
What cannot work inside an app:
- **Sign in with Google** (or another provider that refuses embedded browsers) on the proxy's page. Google blocks sign-ins inside apps.
- **A password prompt** (Basic Auth). The app cannot answer it.
- **Header or service-token rules only**, without a sign-in page. The app cannot add headers to the requests the TREK interface makes.
For those, let TREK through the proxy instead: a bypass rule for TREK's address in Cloudflare Access, Pangolin, authentik or Authelia, or the password prompt removed for TREK. TREK's own sign-in still protects it. With Cloudflare Access, enrolling the phone in Cloudflare WARP also lets the app through.
## Plain http, the home network and VPNs
The app accepts plain http on purpose, because a server at home is often reached as `http://192.168.x.x:3001` or over a VPN such as Tailscale. Use https for anything reachable from the internet. iOS asks once for access to the local network; allow it, or the app cannot reach a server on your network.