diff --git a/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java b/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java index 9de23bb52..64c27faf5 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java +++ b/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java @@ -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); diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java b/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java index 49c4a5623..f663605e0 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java @@ -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 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 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 getSignInHosts(Context context) { + Set 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(); diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java index 7d2d553d5..9c497b1df 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java @@ -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 signInHostsOf(PluginCall call) { + Set 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); diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java b/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java index b60c81e94..ef5eb285c 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java @@ -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 signInHosts; - TrekWebViewClient(Bridge bridge, Runnable onUnreachable) { + TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set 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). */ diff --git a/client/ios/App/App/TrekBridgeViewController.swift b/client/ios/App/App/TrekBridgeViewController.swift index 2c37e7982..16255dcce 100644 --- a/client/ios/App/App/TrekBridgeViewController.swift +++ b/client/ios/App/App/TrekBridgeViewController.swift @@ -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 { + 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) + } +} diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index 99b05e9de..79e119df7 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -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() } diff --git a/client/native-shell/main.ts b/client/native-shell/main.ts index 3640602e3..6cae5b9e5 100644 --- a/client/native-shell/main.ts +++ b/client/native-shell/main.ts @@ -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 { +async function connected(url: string, signInHosts: string[] = []): Promise { void notify(NotificationType.Success); const host = (() => { try { @@ -968,7 +1023,7 @@ async function connected(url: string): Promise { 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 { diff --git a/client/native-shell/shell.css b/client/native-shell/shell.css index 5cb174a84..ff4d26bbb 100644 --- a/client/native-shell/shell.css +++ b/client/native-shell/shell.css @@ -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; diff --git a/client/src/native/authGate.test.ts b/client/src/native/authGate.test.ts new file mode 100644 index 000000000..b0f2e438c --- /dev/null +++ b/client/src/native/authGate.test.ts @@ -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) => `Sign in${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('
Get a login code emailed to you
'), + }); + 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('
Authelia
'), + }) + ).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(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(''), + }); + expect(gate).toEqual({ kind: 'unknown', loginHost: 'sso.example.com' }); + expect(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('

Welcome to nginx

') }) + ).toBeNull(); + }); +}); diff --git a/client/src/native/authGate.ts b/client/src/native/authGate.ts new file mode 100644 index 000000000..3d00a67ed --- /dev/null +++ b/client/src/native/authGate.ts @@ -0,0 +1,99 @@ +/** + * 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, 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; +} + +function header(headers: Record | 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); +} + +/** + * 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 requestedHost = hostOf(requested); + const finalHost = hostOf(finalUrl); + const movedHost = finalHost !== null && finalHost !== requestedHost; + const loginHost = movedHost ? finalHost : null; + const html = typeof response.data === 'string' ? response.data : ''; + let path = ''; + try { + path = new URL(finalUrl).pathname; + } catch { + /* an unparsable URL leaves only the page itself to go by */ + } + + if (finalHost?.endsWith('.cloudflareaccess.com') || header(response.headers, 'cf-access-domain')) { + return { kind: 'cloudflare', loginHost }; + } + if (path.includes('/outpost.goauthentik.io/') || /authentik/i.test(html)) return { kind: 'authentik', loginHost }; + if (/authelia/i.test(html)) return { kind: 'authelia', loginHost }; + if (path.startsWith('/oauth2/') || /oauth2[\s-]?proxy/i.test(html)) return { kind: 'oauth2proxy', loginHost }; + if (path.startsWith('/auth/resource/') || /pangolin/i.test(html)) return { kind: 'pangolin', loginHost }; + if (response.status === 401 && /^basic\b/i.test(header(response.headers, 'www-authenticate'))) { + return { kind: 'basic', loginHost: null }; + } + // Anything else counts only when it reads as a sign-in: a server that is + // simply something other than TREK answers with a page of its own too. + if (html && looksLikeSignIn(html) && (movedHost || [200, 401, 403].includes(response.status))) { + return { kind: 'unknown', loginHost }; + } + return null; +} + +/** 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'; +} diff --git a/client/src/native/links.ts b/client/src/native/links.ts index a0759ae5a..3f0935f90 100644 --- a/client/src/native/links.ts +++ b/client/src/native/links.ts @@ -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'; diff --git a/client/src/native/serverAddress.test.ts b/client/src/native/serverAddress.test.ts index a66508093..934855db1 100644 --- a/client/src/native/serverAddress.test.ts +++ b/client/src/native/serverAddress.test.ts @@ -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: 'Pangolin
', + } 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 => + url.endsWith('/api/health/features') + ? { status: 401, url, data: 'Authelia' } + : { + status: 200, + url: 'https://auth.example.com/?rd=https%3A%2F%2Ftrek.example.com%2F', + data: 'Authelia', + } + ); + 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' }); + }); +}); diff --git a/client/src/native/serverAddress.ts b/client/src/native/serverAddress.ts index d2a51305f..8f9ab06a7 100644 --- a/client/src/native/serverAddress.ts +++ b/client/src/native/serverAddress.ts @@ -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; } export type ProbeGet = (url: string) => Promise; @@ -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 = { invalid: 0, unreachable: 1, notTrek: 2, tooOld: 3 }; +const FAILURE_RANK: Record = { 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 { + 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 { 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. */ diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index 30a1c2db5..50aeef8f4 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -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; + /** + * 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; /** Forgets the server and returns to the address screen. */ resetServer(): Promise; /** Loads the remembered server again, after it could not be reached. */ diff --git a/shared/src/i18n/ar/native.ts b/shared/src/i18n/ar/native.ts index 84211381c..9a1433404 100644 --- a/shared/src/i18n/ar/native.ts +++ b/shared/src/i18n/ar/native.ts @@ -130,6 +130,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; diff --git a/shared/src/i18n/az/native.ts b/shared/src/i18n/az/native.ts index cfc54d242..b80d39838 100644 --- a/shared/src/i18n/az/native.ts +++ b/shared/src/i18n/az/native.ts @@ -134,6 +134,15 @@ 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; diff --git a/shared/src/i18n/br/native.ts b/shared/src/i18n/br/native.ts index 2b4da27e8..cf01b4546 100644 --- a/shared/src/i18n/br/native.ts +++ b/shared/src/i18n/br/native.ts @@ -133,6 +133,15 @@ 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; diff --git a/shared/src/i18n/ca/native.ts b/shared/src/i18n/ca/native.ts index 98040c025..365ed9df4 100644 --- a/shared/src/i18n/ca/native.ts +++ b/shared/src/i18n/ca/native.ts @@ -134,6 +134,15 @@ 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; diff --git a/shared/src/i18n/cs/native.ts b/shared/src/i18n/cs/native.ts index 09628ff36..82c6bca1a 100644 --- a/shared/src/i18n/cs/native.ts +++ b/shared/src/i18n/cs/native.ts @@ -132,6 +132,15 @@ 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; diff --git a/shared/src/i18n/de/native.ts b/shared/src/i18n/de/native.ts index 9ab152362..9da1ab53f 100644 --- a/shared/src/i18n/de/native.ts +++ b/shared/src/i18n/de/native.ts @@ -135,6 +135,15 @@ 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; diff --git a/shared/src/i18n/en/native.ts b/shared/src/i18n/en/native.ts index 80a18ded9..c813427fe 100644 --- a/shared/src/i18n/en/native.ts +++ b/shared/src/i18n/en/native.ts @@ -133,6 +133,15 @@ 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; diff --git a/shared/src/i18n/es/native.ts b/shared/src/i18n/es/native.ts index 0a4ad2853..9c64b2e45 100644 --- a/shared/src/i18n/es/native.ts +++ b/shared/src/i18n/es/native.ts @@ -135,6 +135,15 @@ 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; diff --git a/shared/src/i18n/et/native.ts b/shared/src/i18n/et/native.ts index 1ef4f67be..6d87e8440 100644 --- a/shared/src/i18n/et/native.ts +++ b/shared/src/i18n/et/native.ts @@ -133,6 +133,15 @@ 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; diff --git a/shared/src/i18n/fr/native.ts b/shared/src/i18n/fr/native.ts index e4cde712d..0626af953 100644 --- a/shared/src/i18n/fr/native.ts +++ b/shared/src/i18n/fr/native.ts @@ -135,6 +135,15 @@ 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; diff --git a/shared/src/i18n/gr/native.ts b/shared/src/i18n/gr/native.ts index 7fba4891a..cebcc659d 100644 --- a/shared/src/i18n/gr/native.ts +++ b/shared/src/i18n/gr/native.ts @@ -136,6 +136,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 να περνά από τον proxy.', + 'native.gate.basicHint': 'Η εφαρμογή δεν μπορεί να απαντήσει σε αυτό το αίτημα. Αφήστε το TREK να περνά στον proxy σας: το TREK ζητά ακόμη τη δική του σύνδεση.', + 'native.gate.help': 'Χρήση της εφαρμογής πίσω από proxy σύνδεσης', }; export default native; diff --git a/shared/src/i18n/he/native.ts b/shared/src/i18n/he/native.ts index 166c1aa13..3622368f8 100644 --- a/shared/src/i18n/he/native.ts +++ b/shared/src/i18n/he/native.ts @@ -130,6 +130,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; diff --git a/shared/src/i18n/hu/native.ts b/shared/src/i18n/hu/native.ts index 18ea6ab33..d0d66092e 100644 --- a/shared/src/i18n/hu/native.ts +++ b/shared/src/i18n/hu/native.ts @@ -135,6 +135,15 @@ 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; diff --git a/shared/src/i18n/id/native.ts b/shared/src/i18n/id/native.ts index 869fa7040..03c36c33b 100644 --- a/shared/src/i18n/id/native.ts +++ b/shared/src/i18n/id/native.ts @@ -134,6 +134,15 @@ 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; diff --git a/shared/src/i18n/it/native.ts b/shared/src/i18n/it/native.ts index aa4b1e6a7..075c83a94 100644 --- a/shared/src/i18n/it/native.ts +++ b/shared/src/i18n/it/native.ts @@ -135,6 +135,15 @@ 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; diff --git a/shared/src/i18n/ja/native.ts b/shared/src/i18n/ja/native.ts index ba0945cac..efb9c2bb9 100644 --- a/shared/src/i18n/ja/native.ts +++ b/shared/src/i18n/ja/native.ts @@ -134,6 +134,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 でのサインインはアプリ内では動作しません。お使いのものが Google を使う場合は、プロキシで TREK を通過させてください。', + 'native.gate.basicHint': 'アプリはこの入力に応答できません。プロキシで TREK を通過させてください。TREK は引き続き独自のログインを求めます。', + 'native.gate.help': 'サインインプロキシの背後でアプリを使う', }; export default native; diff --git a/shared/src/i18n/ko/native.ts b/shared/src/i18n/ko/native.ts index 4787e84b1..422735fcf 100644 --- a/shared/src/i18n/ko/native.ts +++ b/shared/src/i18n/ko/native.ts @@ -131,6 +131,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; diff --git a/shared/src/i18n/nl/native.ts b/shared/src/i18n/nl/native.ts index 2cb6fb32b..65e7ef1f5 100644 --- a/shared/src/i18n/nl/native.ts +++ b/shared/src/i18n/nl/native.ts @@ -133,6 +133,15 @@ 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; diff --git a/shared/src/i18n/pl/native.ts b/shared/src/i18n/pl/native.ts index 76efe68e4..f26f8c303 100644 --- a/shared/src/i18n/pl/native.ts +++ b/shared/src/i18n/pl/native.ts @@ -133,6 +133,15 @@ 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; diff --git a/shared/src/i18n/ru/native.ts b/shared/src/i18n/ru/native.ts index 7181b18c8..26db2f77c 100644 --- a/shared/src/i18n/ru/native.ts +++ b/shared/src/i18n/ru/native.ts @@ -134,6 +134,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; diff --git a/shared/src/i18n/sk/native.ts b/shared/src/i18n/sk/native.ts index 0e94c0236..99010112e 100644 --- a/shared/src/i18n/sk/native.ts +++ b/shared/src/i18n/sk/native.ts @@ -132,6 +132,15 @@ 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; diff --git a/shared/src/i18n/sv/native.ts b/shared/src/i18n/sv/native.ts index a811c617c..2cef2d4e7 100644 --- a/shared/src/i18n/sv/native.ts +++ b/shared/src/i18n/sv/native.ts @@ -133,6 +133,15 @@ 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; diff --git a/shared/src/i18n/th/native.ts b/shared/src/i18n/th/native.ts index 4e82ede0d..5c5ef754e 100644 --- a/shared/src/i18n/th/native.ts +++ b/shared/src/i18n/th/native.ts @@ -130,6 +130,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; diff --git a/shared/src/i18n/tr/native.ts b/shared/src/i18n/tr/native.ts index f6d517660..4c40c3b4a 100644 --- a/shared/src/i18n/tr/native.ts +++ b/shared/src/i18n/tr/native.ts @@ -134,6 +134,15 @@ 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; diff --git a/shared/src/i18n/uk/native.ts b/shared/src/i18n/uk/native.ts index 58b854095..748c8d52d 100644 --- a/shared/src/i18n/uk/native.ts +++ b/shared/src/i18n/uk/native.ts @@ -133,6 +133,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; diff --git a/shared/src/i18n/vi/native.ts b/shared/src/i18n/vi/native.ts index a449a4b80..acd68873f 100644 --- a/shared/src/i18n/vi/native.ts +++ b/shared/src/i18n/vi/native.ts @@ -135,6 +135,15 @@ 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; diff --git a/shared/src/i18n/zh-TW/native.ts b/shared/src/i18n/zh-TW/native.ts index 96338dbba..1a95004ef 100644 --- a/shared/src/i18n/zh-TW/native.ts +++ b/shared/src/i18n/zh-TW/native.ts @@ -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; diff --git a/shared/src/i18n/zh/native.ts b/shared/src/i18n/zh/native.ts index 5c4cee27a..347b5e5af 100644 --- a/shared/src/i18n/zh/native.ts +++ b/shared/src/i18n/zh/native.ts @@ -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; diff --git a/wiki/Native-Apps.md b/wiki/Native-Apps.md index 59ca199f3..2390bbb21 100644 --- a/wiki/Native-Apps.md +++ b/wiki/Native-Apps.md @@ -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.