Compare commits

...

3 Commits

50 changed files with 945 additions and 23 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();
@@ -9,6 +9,7 @@ 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;
@@ -18,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. */
@@ -275,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);
@@ -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()
}
+63 -8
View File
@@ -6,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';
@@ -52,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 {
@@ -63,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');
}
@@ -252,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');
@@ -270,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'));
@@ -278,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(
@@ -299,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(
@@ -943,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 {
@@ -968,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 {
+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;
+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';
}
+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. */
@@ -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.