mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-09 22:00:57 +00:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5420077b98 | |||
| 5cfc7a602a | |||
| b48e344749 | |||
| e58a880f34 | |||
| 17ff7f6bd8 | |||
| f03bb97d50 | |||
| ed0142be7d | |||
| 6e9f89a25f | |||
| a3c7c3d76a | |||
| c043ca4bba | |||
| 004c6212c0 | |||
| 07448e662c | |||
| d3bdc6de62 | |||
| a9bfcd0fd5 | |||
| 12caa7fbde | |||
| 1cbd4acf01 | |||
| 687e477ca5 | |||
| 3a66f2a8da | |||
| 7f52e424e8 | |||
| 2479e5661b | |||
| bc2fb1ce2d | |||
| ffa07b3d05 | |||
| 306753d6a1 | |||
| 80c04df4fc | |||
| c9a91bbdad | |||
| d06168d94a | |||
| d9baaa8957 | |||
| fede671f3b | |||
| b3113e66b9 | |||
| 286b0b09ec | |||
| c0b39abe18 | |||
| 6e3a6386b8 | |||
| 5e7d778740 | |||
| 3be87343b8 | |||
| 517efabbca | |||
| 1866dca818 | |||
| 1400b5d308 | |||
| b529cf4e57 | |||
| 6cc3d235b1 | |||
| a5b1156612 | |||
| c6ffd48e26 | |||
| afb6eae4c8 | |||
| cd1f39377d | |||
| 3a99e8576d | |||
| 36c8c5bc48 | |||
| 4ba7db90e6 | |||
| ab6cd7009e | |||
| b16ac683cf |
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.1.1
|
||||
version: 5.1.2
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.1.1"
|
||||
appVersion: "5.1.2"
|
||||
|
||||
@@ -9,7 +9,9 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-action-sheet')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-dialog')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-keyboard')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* A sign-in page in front of the server that the address screen never saw.
|
||||
* A proxy can let the app's check through (an Authelia or Authentik rule that
|
||||
* bypasses /api) and still send the page itself to its sign-in. Handed to the
|
||||
* browser, that sign-in leaves its session there and the app stays blank on
|
||||
* every launch. So a redirect away from the server, while the server's first
|
||||
* page is still loading, is taken as the proxy's sign-in: it loads in the web
|
||||
* view and its host is kept, the way the address screen keeps the ones it saw.
|
||||
* A link the user follows is no redirect, and once a page of the server has
|
||||
* shown, nothing is learned any more.
|
||||
*/
|
||||
final class SignInRedirect {
|
||||
|
||||
private SignInRedirect() {}
|
||||
|
||||
/** The host (host:port with an explicit port) to keep as a sign-in host, or null. */
|
||||
static String learnedHost(String server, String target, boolean mainFrame, boolean redirect, boolean serverShown) {
|
||||
if (!mainFrame || !redirect || serverShown || server == null || target == null) return null;
|
||||
URI from;
|
||||
URI to;
|
||||
try {
|
||||
from = new URI(server);
|
||||
to = new URI(target);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
String fromHost = lower(from.getHost());
|
||||
String toHost = lower(to.getHost());
|
||||
String toScheme = lower(to.getScheme());
|
||||
if (fromHost == null || toHost == null) return null;
|
||||
if (!"https".equals(toScheme) && !"http".equals(toScheme)) return null;
|
||||
// Never from https down to plain http: the sign-in would send a password in the clear.
|
||||
if ("https".equals(lower(from.getScheme())) && !"https".equals(toScheme)) return null;
|
||||
if (toHost.equals(fromHost)) return null;
|
||||
return to.getPort() == -1 ? toHost : toHost + ":" + to.getPort();
|
||||
}
|
||||
|
||||
/** Whether `url` is on the server's host, where the app's own pages come from. */
|
||||
static boolean onServer(String server, String url) {
|
||||
if (server == null || url == null) return false;
|
||||
try {
|
||||
String serverHost = lower(new URI(server).getHost());
|
||||
return serverHost != null && serverHost.equals(lower(new URI(url).getHost()));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String lower(String value) {
|
||||
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -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,26 +22,80 @@ 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 KEY_WELCOME = "welcome";
|
||||
private static final String CONFIG_DIR = "trek-server-config";
|
||||
|
||||
private TrekServerStore() {}
|
||||
|
||||
/**
|
||||
* The one-time code of a TREK Cloud purchase (setServer's `welcome`). The
|
||||
* server's first page opens its welcome page with it once, which signs the
|
||||
* buyer in (TrekWebViewClient). Kept only until the restart has loaded.
|
||||
*/
|
||||
static void setWelcome(Context context, String code) {
|
||||
SharedPreferences.Editor editor = prefs(context).edit();
|
||||
if (code == null) editor.remove(KEY_WELCOME);
|
||||
else editor.putString(KEY_WELCOME, code);
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
/** The welcome page for the pending code, handed out once; null when there is none. */
|
||||
static String takeWelcomeUrl(Context context) {
|
||||
String code = prefs(context).getString(KEY_WELCOME, null);
|
||||
String server = getUrl(context);
|
||||
if (code == null) return null;
|
||||
prefs(context).edit().remove(KEY_WELCOME).commit();
|
||||
if (server == null) return null;
|
||||
return (server.endsWith("/") ? server : server + "/") + "api/managed/welcome#" + code;
|
||||
}
|
||||
|
||||
static String getUrl(Context context) {
|
||||
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));
|
||||
}
|
||||
|
||||
/** Keeps one more sign-in host for the remembered server, as the web view met it (SignInRedirect). */
|
||||
static void addSignInHost(Context context, String host) {
|
||||
Set<String> hosts = new HashSet<>(getSignInHosts(context));
|
||||
if (!hosts.add(host.toLowerCase(Locale.ROOT))) return;
|
||||
prefs(context).edit().putStringSet(KEY_SIGN_IN_HOSTS, hosts).commit();
|
||||
}
|
||||
|
||||
/** Start the next launch on the bundled address screen, even with a server remembered. */
|
||||
static void showShellOnce(Context context) {
|
||||
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
|
||||
|
||||
@@ -6,17 +6,25 @@ import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import androidx.activity.result.ActivityResult;
|
||||
import com.liketrek.trek.clientcert.ClientCertActions;
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity;
|
||||
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;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.ActivityCallback;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import com.getcapacitor.annotation.Permission;
|
||||
import com.getcapacitor.annotation.PermissionCallback;
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** The native half of client/src/native/trekShell.ts. */
|
||||
@@ -29,6 +37,8 @@ import org.json.JSONObject;
|
||||
)
|
||||
public class TrekShellPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "TrekShell";
|
||||
|
||||
/** How long to wait after returning from the browser for the redirect to land. */
|
||||
private static final long CANCEL_GRACE_MS = 600;
|
||||
|
||||
@@ -59,6 +69,7 @@ public class TrekShellPlugin extends Plugin {
|
||||
"googleWallet",
|
||||
"keyCommands",
|
||||
"windowLayout",
|
||||
"clientCertificate",
|
||||
};
|
||||
|
||||
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
|
||||
@@ -272,11 +283,26 @@ public class TrekShellPlugin extends Plugin {
|
||||
call.reject("A http(s) URL is required");
|
||||
return;
|
||||
}
|
||||
TrekServerStore.setUrl(getContext(), url);
|
||||
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
|
||||
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
|
||||
String welcome = call.getString("welcome");
|
||||
TrekServerStore.setWelcome(getContext(), welcome != null && welcome.matches("[A-Za-z0-9_-]{43}") ? welcome : null);
|
||||
call.resolve();
|
||||
restart();
|
||||
}
|
||||
|
||||
/** The sign-in hosts the address screen passes along, plain host names only. */
|
||||
private static Set<String> signInHostsOf(PluginCall call) {
|
||||
Set<String> hosts = new HashSet<>();
|
||||
JSArray list = call.getArray("signInHosts");
|
||||
if (list == null) return hosts;
|
||||
for (int i = 0; i < list.length(); i++) {
|
||||
String host = list.optString(i, "");
|
||||
if (host.matches("[A-Za-z0-9.-]+(:[0-9]+)?")) hosts.add(host);
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void resetServer(PluginCall call) {
|
||||
TrekServerStore.setUrl(getContext(), null);
|
||||
@@ -315,7 +341,32 @@ public class TrekShellPlugin extends Plugin {
|
||||
// A Custom Tab shares the browser's cookies, so a running SSO session at
|
||||
// the identity provider carries over. The redirect back to the app's
|
||||
// scheme arrives through the intent-filter in AndroidManifest.xml.
|
||||
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url));
|
||||
Uri target = Uri.parse(url);
|
||||
getActivity().runOnUiThread(() -> openSignIn(target));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the sign-in in a Custom Tab, or in the browser itself where no
|
||||
* Custom Tab can come up. Launched on the UI thread: some devices dropped a
|
||||
* launch from the bridge's thread without a word, and the sign-in then
|
||||
* looked cancelled. A browser that cannot open at all is reported as such.
|
||||
*/
|
||||
private void openSignIn(Uri target) {
|
||||
try {
|
||||
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), target);
|
||||
return;
|
||||
} catch (RuntimeException tabFailed) {
|
||||
Log.w(TAG, "No Custom Tab for the sign-in, trying the browser", tabFailed);
|
||||
}
|
||||
try {
|
||||
getActivity().startActivity(new Intent(Intent.ACTION_VIEW, target).addCategory(Intent.CATEGORY_BROWSABLE));
|
||||
} catch (RuntimeException browserFailed) {
|
||||
Log.w(TAG, "No browser for the sign-in", browserFailed);
|
||||
PluginCall call = pendingAuthCall();
|
||||
if (call == null) return;
|
||||
call.reject("No browser could open the sign-in", "NO_BROWSER");
|
||||
finishAuth(call);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -630,4 +681,129 @@ public class TrekShellPlugin extends Plugin {
|
||||
// The last layout WindowManager reported, posture and hinge included; the bare window before the first.
|
||||
getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.current(getActivity())));
|
||||
}
|
||||
|
||||
// ---- Client certificates (mutual TLS) ----
|
||||
|
||||
/**
|
||||
* The address screen's check of a server, made natively so it notices
|
||||
* whether the server asks for a client certificate and can show the stored
|
||||
* one (clientcert/ClientCertActions.request).
|
||||
*/
|
||||
@PluginMethod
|
||||
public void requestServer(PluginCall call) {
|
||||
String url = call.getString("url");
|
||||
Uri parsed = url == null ? null : Uri.parse(url);
|
||||
if (parsed == null || parsed.getHost() == null || !("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()))) {
|
||||
call.reject("A http(s) URL is required");
|
||||
return;
|
||||
}
|
||||
int timeout = Math.max(1000, Math.min(call.getInt("timeoutMs", 10000), 30000));
|
||||
new Thread(() -> {
|
||||
try {
|
||||
call.resolve(ClientCertActions.request(getContext(), url, timeout));
|
||||
} catch (Exception e) {
|
||||
call.reject(e.getMessage() == null ? "request failed" : e.getMessage());
|
||||
}
|
||||
}, "trek-request-server").start();
|
||||
}
|
||||
|
||||
/** The stored certificate's details, or none. */
|
||||
@PluginMethod
|
||||
public void clientCertificate(PluginCall call) {
|
||||
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
|
||||
JSObject result = new JSObject();
|
||||
result.put("certificate", info == null ? JSONObject.NULL : ClientCertActions.infoObject(info));
|
||||
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
|
||||
if (asked != null) {
|
||||
JSObject request = new JSObject();
|
||||
request.put("host", asked.getHost());
|
||||
request.put("port", asked.getPort());
|
||||
request.put("served", asked.getServed());
|
||||
result.put("request", request);
|
||||
}
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void removeClientCertificate(PluginCall call) {
|
||||
TrekClientIdentity.remove(getContext());
|
||||
forgetCertificateAnswers();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** The WebView keeps its answer to a certificate request per host; a changed certificate needs a fresh one. */
|
||||
private void forgetCertificateAnswers() {
|
||||
com.liketrek.trek.clientcert.ClientCertWatch.reset();
|
||||
getActivity().runOnUiThread(() -> android.webkit.WebView.clearClientCertPreferences(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a .p12/.pfx file, asks for its password until it opens or the user
|
||||
* gives up, and keeps it for the server at host and port. Resolves with
|
||||
* result "imported" (and the certificate), "cancelled", or "failed" and why.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void importClientCertificate(PluginCall call) {
|
||||
String host = call.getString("host");
|
||||
if (host == null || !host.matches("[A-Za-z0-9.-]+")) {
|
||||
call.reject("A host is required");
|
||||
return;
|
||||
}
|
||||
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*");
|
||||
pick.putExtra(Intent.EXTRA_MIME_TYPES, new String[] { "application/x-pkcs12", "application/pkcs12", "application/octet-stream" });
|
||||
startActivityForResult(call, pick, "clientCertificatePicked");
|
||||
}
|
||||
|
||||
@ActivityCallback
|
||||
private void clientCertificatePicked(PluginCall call, ActivityResult picked) {
|
||||
if (call == null) return;
|
||||
Uri uri = picked.getData() == null ? null : picked.getData().getData();
|
||||
if (picked.getResultCode() != android.app.Activity.RESULT_OK || uri == null) {
|
||||
resolveImport(call, "cancelled", null);
|
||||
return;
|
||||
}
|
||||
new Thread(() -> {
|
||||
byte[] bytes = ClientCertActions.readFile(getContext(), uri);
|
||||
if (bytes == null) resolveImport(call, "failed", TrekClientIdentity.Failure.FORMAT.name());
|
||||
else askPasswordFor(call, bytes, false);
|
||||
}, "trek-client-cert").start();
|
||||
}
|
||||
|
||||
private void askPasswordFor(PluginCall call, byte[] bytes, boolean wrong) {
|
||||
JSObject labels = call.getObject("labels", new JSObject());
|
||||
ClientCertActions.askPassword(getActivity(), labels, wrong, password -> {
|
||||
if (password == null) {
|
||||
resolveImport(call, "cancelled", null);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}
|
||||
new Thread(() -> openWith(call, bytes, password), "trek-client-cert").start();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void openWith(PluginCall call, byte[] bytes, char[] password) {
|
||||
try {
|
||||
TrekClientIdentity.Identity identity = TrekClientIdentity.read(bytes, password);
|
||||
TrekClientIdentity.save(getContext(), identity, call.getString("host"), call.getInt("port", 443));
|
||||
forgetCertificateAnswers();
|
||||
resolveImport(call, "imported", null);
|
||||
} catch (TrekClientIdentity.ImportException e) {
|
||||
if (e.getReason() == TrekClientIdentity.Failure.PASSWORD) askPasswordFor(call, bytes, true);
|
||||
else resolveImport(call, "failed", e.getReason().name());
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "client certificate not kept", e);
|
||||
resolveImport(call, "failed", "STORE");
|
||||
} finally {
|
||||
java.util.Arrays.fill(password, '\0');
|
||||
}
|
||||
}
|
||||
|
||||
private void resolveImport(PluginCall call, String result, String failure) {
|
||||
JSObject out = new JSObject();
|
||||
out.put("result", result);
|
||||
if (failure != null) out.put("failure", failure.toLowerCase(java.util.Locale.ROOT));
|
||||
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
|
||||
if ("imported".equals(result) && info != null) out.put("certificate", ClientCertActions.infoObject(info));
|
||||
call.resolve(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.webkit.ClientCertRequest;
|
||||
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 com.liketrek.trek.clientcert.TrekClientIdentity;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Used while the WebView shows the user's server. When that server cannot be
|
||||
@@ -18,10 +24,58 @@ import com.getcapacitor.BridgeWebViewClient;
|
||||
class TrekWebViewClient extends BridgeWebViewClient {
|
||||
|
||||
private final Runnable onUnreachable;
|
||||
private final Set<String> signInHosts;
|
||||
/** A page of the server has shown since launch; from then on no sign-in host is learned (SignInRedirect). */
|
||||
private boolean serverShown;
|
||||
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) {
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
|
||||
super(bridge);
|
||||
this.onUnreachable = onUnreachable;
|
||||
this.signInHosts = new HashSet<>(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;
|
||||
String learned = SignInRedirect.learnedHost(
|
||||
TrekServerStore.getUrl(view.getContext()),
|
||||
request.getUrl().toString(),
|
||||
request.isForMainFrame(),
|
||||
request.isRedirect(),
|
||||
serverShown
|
||||
);
|
||||
if (learned != null) {
|
||||
signInHosts.add(learned);
|
||||
TrekServerStore.addSignInHost(view.getContext(), learned);
|
||||
return false;
|
||||
}
|
||||
return super.shouldOverrideUrlLoading(view, request);
|
||||
}
|
||||
|
||||
/** After the server's first page, a pending welcome code opens the welcome page once (TrekServerStore.takeWelcomeUrl). */
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
super.onPageFinished(view, url);
|
||||
if (SignInRedirect.onServer(TrekServerStore.getUrl(view.getContext()), url)) serverShown = true;
|
||||
String welcome = TrekServerStore.takeWelcomeUrl(view.getContext());
|
||||
if (welcome != null) view.loadUrl(welcome);
|
||||
}
|
||||
|
||||
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). */
|
||||
@@ -34,6 +88,28 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
return super.shouldInterceptRequest(view, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* A proxy in front of the server asks for a client certificate (mutual TLS).
|
||||
* The stored one goes only to the host and port it was imported for; every
|
||||
* other site that asks is told there is none, as a browser without a
|
||||
* matching certificate would. The WebView remembers either answer for the
|
||||
* host until the app restarts, which a new certificate always brings along.
|
||||
*/
|
||||
@Override
|
||||
public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) {
|
||||
TrekClientIdentity.Identity identity = TrekClientIdentity.identityFor(view.getContext(), request.getHost(), request.getPort());
|
||||
TrekClientIdentity.noteRequest(request.getHost(), request.getPort(), identity != null);
|
||||
if (identity != null) request.proceed(identity.getKey(), identity.getChain());
|
||||
else request.ignore();
|
||||
// The page may still come from the service worker's cache while every
|
||||
// request fails: check natively whether the server lets the app in.
|
||||
String server = TrekServerStore.getUrl(view.getContext());
|
||||
Uri serverUri = server == null ? null : Uri.parse(server);
|
||||
if (serverUri != null && TrekClientIdentity.matches(String.valueOf(serverUri.getHost()), serverUri.getPort() > 0 ? serverUri.getPort() : 443, request.getHost(), request.getPort())) {
|
||||
com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, onUnreachable);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||
if (request.isForMainFrame()) {
|
||||
@@ -46,14 +122,22 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
/**
|
||||
* Only a 5xx on the page itself means the server is down (a proxy's 502 or
|
||||
* 503 while TREK restarts). A 4xx page is left alone: the server answered,
|
||||
* and TREK serves its UI for every other path.
|
||||
* and TREK serves its UI for every other path. The exception is a proxy
|
||||
* that asked this page for a client certificate and got none, as Cloudflare
|
||||
* does with a 403: the address screen then says so and offers to add one.
|
||||
*/
|
||||
@Override
|
||||
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
|
||||
int status = errorResponse.getStatusCode();
|
||||
if (!request.isForMainFrame()) {
|
||||
super.onReceivedHttpError(view, request, errorResponse);
|
||||
} else if (errorResponse.getStatusCode() >= 500) {
|
||||
} else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) {
|
||||
onUnreachable.run();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean wantedCertificate(Uri url) {
|
||||
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
|
||||
return asked != null && !asked.getServed() && TrekClientIdentity.matches(asked.getHost(), asked.getPort(), url.getHost(), url.getPort());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.liketrek.trek.clientcert
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.text.InputType
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import com.getcapacitor.JSObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
||||
/**
|
||||
* What TrekShellPlugin does for client certificates (TrekClientIdentity):
|
||||
* check a server while noticing whether it asks for one, read a picked file,
|
||||
* and ask for its password.
|
||||
*/
|
||||
object ClientCertActions {
|
||||
|
||||
private const val MAX_BODY_BYTES = 512 * 1024
|
||||
private const val MAX_FILE_BYTES = 256 * 1024
|
||||
private const val MAX_REDIRECTS = 5
|
||||
|
||||
/**
|
||||
* A GET against the user's server for the address screen: status, body, final
|
||||
* URL and headers, plus whether the server asked for a client certificate and
|
||||
* whether one was shown. A failed connection answers status 0 instead of
|
||||
* throwing, so a handshake the server broke off for want of a certificate
|
||||
* still reports that it asked.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun request(context: Context, url: String, timeoutMs: Int): JSObject {
|
||||
var asked: Triple<String, Int, Boolean>? = null
|
||||
val factory = TrekClientIdentity.socketFactory(context) { host, port, served -> asked = Triple(host, port, served) }
|
||||
val result = JSObject()
|
||||
var current = URL(url)
|
||||
try {
|
||||
for (hop in 0..MAX_REDIRECTS) {
|
||||
val connection = current.openConnection() as HttpURLConnection
|
||||
if (connection is HttpsURLConnection) connection.sslSocketFactory = factory
|
||||
connection.instanceFollowRedirects = false
|
||||
connection.connectTimeout = timeoutMs
|
||||
connection.readTimeout = timeoutMs
|
||||
connection.setRequestProperty("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
|
||||
try {
|
||||
val status = connection.responseCode
|
||||
val location = connection.getHeaderField("Location")
|
||||
if (status in 300..399 && location != null && hop < MAX_REDIRECTS) {
|
||||
current = URL(current, location)
|
||||
continue
|
||||
}
|
||||
val stream = if (status >= 400) connection.errorStream else connection.inputStream
|
||||
result.put("status", status)
|
||||
result.put("data", stream?.use { readCapped(it, MAX_BODY_BYTES) }?.toString(Charsets.UTF_8) ?: "")
|
||||
result.put("url", current.toString())
|
||||
val headers = JSObject()
|
||||
for ((name, values) in connection.headerFields) if (name != null && values.isNotEmpty()) headers.put(name, values.first())
|
||||
result.put("headers", headers)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
break
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
result.put("status", 0)
|
||||
result.put("data", "")
|
||||
result.put("url", current.toString())
|
||||
result.put("error", e.javaClass.simpleName)
|
||||
}
|
||||
val seen = asked
|
||||
result.put("certRequested", seen != null)
|
||||
result.put("certSent", seen?.third == true)
|
||||
if (seen != null) result.put("certHost", "${seen.first}:${seen.second}")
|
||||
return result
|
||||
}
|
||||
|
||||
/** The bytes of a picked file, or null when it cannot be read or is far too big for a certificate. */
|
||||
@JvmStatic
|
||||
fun readFile(context: Context, uri: Uri): ByteArray? = try {
|
||||
context.contentResolver.openInputStream(uri)?.use { readCapped(it, MAX_FILE_BYTES + 1) }?.takeIf { it.size <= MAX_FILE_BYTES }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* The password prompt, with the UI's own words (`labels`: title, message,
|
||||
* placeholder, ok, cancel, wrongPassword). `wrong` shows that the last try
|
||||
* did not open the file. `done` gets the password, or null when cancelled.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun askPassword(activity: Activity, labels: JSObject, wrong: Boolean, done: (CharArray?) -> Unit) {
|
||||
activity.runOnUiThread {
|
||||
val input = EditText(activity).apply {
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
hint = labels.getString("placeholder") ?: ""
|
||||
}
|
||||
val frame = FrameLayout(activity).apply {
|
||||
val pad = (20 * activity.resources.displayMetrics.density).toInt()
|
||||
setPadding(pad, pad / 2, pad, 0)
|
||||
addView(input, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||
}
|
||||
val message = if (wrong) labels.getString("wrongPassword") else labels.getString("message")
|
||||
var answered = false
|
||||
val dialog = AlertDialog.Builder(activity)
|
||||
.setTitle(labels.getString("title"))
|
||||
.setMessage(message)
|
||||
.setView(frame)
|
||||
.setPositiveButton(labels.getString("ok") ?: "OK") { _, _ ->
|
||||
answered = true
|
||||
val text = input.text
|
||||
val password = CharArray(text.length) { text[it] }
|
||||
done(password)
|
||||
}
|
||||
.setNegativeButton(labels.getString("cancel") ?: "Cancel") { _, _ ->
|
||||
answered = true
|
||||
done(null)
|
||||
}
|
||||
.setOnDismissListener { if (!answered) done(null) }
|
||||
.create()
|
||||
dialog.show()
|
||||
input.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
/** The settings' view of the stored certificate. */
|
||||
@JvmStatic
|
||||
fun infoObject(info: TrekClientIdentity.Info): JSObject = JSObject().apply {
|
||||
put("host", info.host)
|
||||
put("port", info.port)
|
||||
put("subject", info.subject)
|
||||
put("issuer", info.issuer)
|
||||
put("notAfter", info.notAfter)
|
||||
}
|
||||
|
||||
private fun readCapped(stream: InputStream, max: Int): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8192)
|
||||
while (out.size() < max) {
|
||||
val n = stream.read(buffer, 0, minOf(buffer.size, max - out.size()))
|
||||
if (n < 0) break
|
||||
out.write(buffer, 0, n)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.liketrek.trek.clientcert
|
||||
|
||||
import android.content.Context
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Notices when the user's server stops letting the app in for want of a
|
||||
* client certificate: one removed, expired, or no longer trusted by the proxy.
|
||||
*
|
||||
* The page alone does not show it. The service worker serves TREK's start page
|
||||
* from its cache, so the page loads and only its requests fail, and the UI
|
||||
* reports a server it cannot reach with no way to add a certificate. So when
|
||||
* the server asks for one, the app checks once natively whether TREK answers
|
||||
* with what it holds. Only a check that fails at the certificate (the handshake
|
||||
* broke off, or the proxy answered with its own 4xx) sends the app to the
|
||||
* address screen, which says so and offers to add one. A server that merely
|
||||
* asks and lets everyone in answers TREK's 200, and nothing happens.
|
||||
*/
|
||||
object ClientCertWatch {
|
||||
|
||||
private const val MIN_INTERVAL_MS = 60_000L
|
||||
private const val FEATURES_PATH = "/api/health/features"
|
||||
private const val TIMEOUT_MS = 8000
|
||||
|
||||
private val lastCheck = AtomicLong(0)
|
||||
|
||||
/** Called where the server asked for a certificate; `onRefused` runs at most once a minute, on a background thread. */
|
||||
@JvmStatic
|
||||
fun serverAsked(context: Context, serverUrl: String?, onRefused: Runnable) {
|
||||
if (serverUrl == null) return
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastCheck.get()
|
||||
if (now - last < MIN_INTERVAL_MS || !lastCheck.compareAndSet(last, now)) return
|
||||
val appContext = context.applicationContext
|
||||
Thread({
|
||||
val result = ClientCertActions.request(appContext, serverUrl.trimEnd('/') + FEATURES_PATH, TIMEOUT_MS)
|
||||
if (refused(result.optBoolean("certRequested"), result.optInt("status"))) onRefused.run()
|
||||
}, "trek-client-cert-watch").start()
|
||||
}
|
||||
|
||||
/** A new or removed certificate is worth a check of its own, whenever the last one ran. */
|
||||
@JvmStatic
|
||||
fun reset() {
|
||||
lastCheck.set(0)
|
||||
}
|
||||
|
||||
/** The server asked for a certificate and did not answer with TREK. */
|
||||
@JvmStatic
|
||||
fun refused(certRequested: Boolean, status: Int): Boolean = certRequested && (status == 0 || status in 400..499)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.liketrek.trek.clientcert
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.net.Socket
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.security.PrivateKey
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
|
||||
/**
|
||||
* The client certificate a user's TREK server asks for (mutual TLS): a proxy
|
||||
* such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through only
|
||||
* devices that show one. The app holds at most one, for one server.
|
||||
*
|
||||
* It comes in as a PKCS#12 file (.p12, .pfx) and its password. Neither is kept:
|
||||
* the private key is sealed with an AES key of the Android keystore, which
|
||||
* never leaves it, and stored with the certificate chain in the app's private
|
||||
* preferences. In memory the key is an ordinary one, which TLS 1.2 and 1.3 and
|
||||
* the WebView all take, whatever its kind; a key imported into the keystore
|
||||
* itself would be refused by some of them for its padding or digest rules.
|
||||
*
|
||||
* Only the host and port it was imported for ever see it (`identityFor`), so a
|
||||
* login page, a map tile provider or liketrek.com never learns of it.
|
||||
*/
|
||||
object TrekClientIdentity {
|
||||
|
||||
private const val PREFS = "trek_client_identity"
|
||||
private const val WRAP_ALIAS = "trek-client-identity-wrap"
|
||||
private const val KEYMANAGER_ALIAS = "trek-client-identity"
|
||||
private const val GCM_TAG_BITS = 128
|
||||
|
||||
/** Why a file could not be taken. */
|
||||
enum class Failure { PASSWORD, FORMAT, NO_KEY, EXPIRED, NOT_YET_VALID }
|
||||
|
||||
class ImportException(val reason: Failure) : Exception(reason.name)
|
||||
|
||||
class Identity(val key: PrivateKey, val chain: Array<X509Certificate>)
|
||||
|
||||
/** What the settings show: who the certificate names, who issued it, until when, and for which server. */
|
||||
data class Info(val host: String, val port: Int, val subject: String, val issuer: String, val notAfter: Long)
|
||||
|
||||
/** The opened identity, with the sealed key text it was opened from: opened once, not per request. */
|
||||
@Volatile private var cached: Pair<String, Identity>? = null
|
||||
|
||||
/** The last time a server asked for a certificate while the app showed it: for the address screen. */
|
||||
@Volatile var lastRequest: Request? = null
|
||||
private set
|
||||
|
||||
data class Request(val host: String, val port: Int, val served: Boolean)
|
||||
|
||||
private class Stored(val host: String, val port: Int, val algorithm: String, val sealedText: String, val iv: ByteArray, val chain: List<ByteArray>)
|
||||
|
||||
/**
|
||||
* Reads a PKCS#12 file: the first entry that holds a private key, with its chain.
|
||||
* Throws ImportException naming what is wrong with it.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
@Throws(ImportException::class)
|
||||
fun read(bytes: ByteArray, password: CharArray, now: Date = Date()): Identity {
|
||||
val store = KeyStore.getInstance("PKCS12")
|
||||
try {
|
||||
store.load(ByteArrayInputStream(bytes), password)
|
||||
} catch (e: IOException) {
|
||||
throw ImportException(if (isPasswordFailure(e)) Failure.PASSWORD else Failure.FORMAT)
|
||||
} catch (e: Exception) {
|
||||
throw ImportException(Failure.FORMAT)
|
||||
}
|
||||
val alias = store.aliases().toList().firstOrNull { store.isKeyEntry(it) } ?: throw ImportException(Failure.NO_KEY)
|
||||
val key = try {
|
||||
store.getKey(alias, password) as? PrivateKey
|
||||
} catch (e: UnrecoverableKeyException) {
|
||||
throw ImportException(Failure.PASSWORD)
|
||||
} ?: throw ImportException(Failure.NO_KEY)
|
||||
val chain = store.getCertificateChain(alias)?.mapNotNull { it as? X509Certificate }?.toTypedArray()
|
||||
if (chain.isNullOrEmpty()) throw ImportException(Failure.NO_KEY)
|
||||
val leaf = chain[0]
|
||||
if (now.after(leaf.notAfter)) throw ImportException(Failure.EXPIRED)
|
||||
if (now.before(leaf.notBefore)) throw ImportException(Failure.NOT_YET_VALID)
|
||||
return Identity(key, chain)
|
||||
}
|
||||
|
||||
/** A wrong password shows up as a failed MAC check or an unrecoverable key, wrapped in an IOException. */
|
||||
private fun isPasswordFailure(e: Throwable): Boolean {
|
||||
var cause: Throwable? = e
|
||||
while (cause != null) {
|
||||
if (cause is UnrecoverableKeyException || cause is javax.crypto.BadPaddingException) return true
|
||||
if (cause.message?.lowercase(Locale.ROOT)?.contains("password") == true) return true
|
||||
cause = cause.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Keeps the identity for this host and port, replacing any other. */
|
||||
@JvmStatic
|
||||
fun save(context: Context, identity: Identity, host: String, port: Int) {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, wrapKey())
|
||||
val sealed = cipher.doFinal(identity.key.encoded)
|
||||
prefs(context).edit()
|
||||
.putString("host", host.lowercase(Locale.ROOT))
|
||||
.putInt("port", port)
|
||||
.putString("algorithm", identity.key.algorithm)
|
||||
.putString("key", encode(sealed))
|
||||
.putString("iv", encode(cipher.iv))
|
||||
.putString("chain", identity.chain.joinToString(",") { encode(it.encoded) })
|
||||
.apply()
|
||||
cached = null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun remove(context: Context) {
|
||||
prefs(context).edit().clear().apply()
|
||||
cached = null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun info(context: Context): Info? {
|
||||
val stored = stored(context) ?: return null
|
||||
val leaf = runCatching { certificate(stored.chain.first()) }.getOrNull() ?: return null
|
||||
return Info(stored.host, stored.port, commonName(leaf.subjectX500Principal), commonName(leaf.issuerX500Principal), leaf.notAfter.time)
|
||||
}
|
||||
|
||||
/** The identity for a server at this host and port, or null when there is none for it. */
|
||||
@JvmStatic
|
||||
fun identityFor(context: Context, host: String?, port: Int): Identity? {
|
||||
val stored = stored(context) ?: return null
|
||||
if (!matches(stored.host, stored.port, host, port)) return null
|
||||
return opened(stored)
|
||||
}
|
||||
|
||||
private fun opened(stored: Stored): Identity? {
|
||||
cached?.let { (from, identity) -> if (from == stored.sealedText) return identity }
|
||||
val identity = runCatching { open(stored) }.getOrNull() ?: return null
|
||||
cached = stored.sealedText to identity
|
||||
return identity
|
||||
}
|
||||
|
||||
/** Whether a request to host and port (-1 or 0 for the scheme's default) goes to the server the identity is for. */
|
||||
@JvmStatic
|
||||
fun matches(storedHost: String, storedPort: Int, host: String?, port: Int): Boolean {
|
||||
if (host == null || !host.equals(storedHost, ignoreCase = true)) return false
|
||||
val effective = if (port <= 0) 443 else port
|
||||
return effective == storedPort
|
||||
}
|
||||
|
||||
/** Called where a server asked for a certificate, so the address screen can offer one. */
|
||||
@JvmStatic
|
||||
fun noteRequest(host: String?, port: Int, served: Boolean) {
|
||||
if (host == null) return
|
||||
lastRequest = Request(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, served)
|
||||
}
|
||||
|
||||
/**
|
||||
* A socket factory for the app's own requests (the server check, map tiles).
|
||||
* It offers the identity only when the server it is talking to asks for one
|
||||
* and is the one the identity is for, and tells `onAsked` about every ask.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun socketFactory(context: Context, onAsked: ((host: String, port: Int, served: Boolean) -> Unit)? = null): SSLSocketFactory {
|
||||
val appContext = context.applicationContext
|
||||
val manager = object : X509ExtendedKeyManager() {
|
||||
// Decided per handshake in choose(); the key and chain are then read for
|
||||
// the one alias, so parallel connections never see each other's choice.
|
||||
private fun choose(host: String?, port: Int): String? {
|
||||
val identity = identityFor(appContext, host, port)
|
||||
if (host != null) {
|
||||
noteRequest(host, port, identity != null)
|
||||
onAsked?.invoke(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, identity != null)
|
||||
}
|
||||
return if (identity != null) KEYMANAGER_ALIAS else null
|
||||
}
|
||||
|
||||
override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? {
|
||||
val session = (socket as? SSLSocket)?.handshakeSession
|
||||
return choose(session?.peerHost, session?.peerPort ?: -1)
|
||||
}
|
||||
|
||||
override fun chooseEngineClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, engine: SSLEngine?): String? =
|
||||
choose(engine?.peerHost, engine?.peerPort ?: -1)
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? =
|
||||
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.chain else null
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? =
|
||||
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.key else null
|
||||
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
|
||||
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
|
||||
override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String? = null
|
||||
}
|
||||
val tls = SSLContext.getInstance("TLS")
|
||||
tls.init(arrayOf(manager), null, null)
|
||||
return tls.socketFactory
|
||||
}
|
||||
|
||||
private fun open(stored: Stored): Identity {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), GCMParameterSpec(GCM_TAG_BITS, stored.iv))
|
||||
val key = KeyFactory.getInstance(stored.algorithm).generatePrivate(PKCS8EncodedKeySpec(cipher.doFinal(decode(stored.sealedText))))
|
||||
return Identity(key, stored.chain.map { certificate(it) }.toTypedArray())
|
||||
}
|
||||
|
||||
private fun stored(context: Context): Stored? {
|
||||
val p = prefs(context)
|
||||
val host = p.getString("host", null) ?: return null
|
||||
val algorithm = p.getString("algorithm", null) ?: return null
|
||||
val key = p.getString("key", null) ?: return null
|
||||
val iv = p.getString("iv", null) ?: return null
|
||||
val chain = p.getString("chain", null)?.split(",")?.filter { it.isNotEmpty() }?.map { decode(it) } ?: return null
|
||||
if (chain.isEmpty()) return null
|
||||
return Stored(host, p.getInt("port", 443), algorithm, key, decode(iv), chain)
|
||||
}
|
||||
|
||||
/** The keystore's AES key that seals the private key; made once, never leaves the keystore. */
|
||||
private fun wrapKey(): SecretKey {
|
||||
val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(keystore.getKey(WRAP_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
private fun certificate(der: ByteArray): X509Certificate =
|
||||
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(der)) as X509Certificate
|
||||
|
||||
/** The CN of a name, or the whole name when it has none. */
|
||||
@JvmStatic
|
||||
fun commonName(principal: javax.security.auth.x500.X500Principal): String {
|
||||
val name = principal.getName(javax.security.auth.x500.X500Principal.RFC2253)
|
||||
return Regex("(?:^|,)CN=((?:\\\\.|[^,])*)").find(name)?.groupValues?.get(1)?.replace("\\", "") ?: name
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun encode(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
private fun decode(text: String) = Base64.decode(text, Base64.NO_WRAP)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.liketrek.trek
|
||||
|
||||
import com.liketrek.trek.clientcert.ClientCertWatch
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity.Failure
|
||||
import java.util.Date
|
||||
import javax.security.auth.x500.X500Principal
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Reading a client certificate file (TrekClientIdentity.read) and the rule for
|
||||
* which server sees it. The files in test/resources/clientcert are test-only:
|
||||
* a throwaway CA, password "trek-test". valid.p12 has OpenSSL 3's default
|
||||
* encryption (AES-256, PBKDF2), valid-legacy.p12 the older RC2/3DES one that
|
||||
* many routers and NAS systems still export.
|
||||
*/
|
||||
class ClientIdentityTest {
|
||||
private val password = "trek-test".toCharArray()
|
||||
private val inside = Date(1_800_000_000_000L) // 2027-01-15, inside every valid test certificate
|
||||
|
||||
private fun file(name: String): ByteArray =
|
||||
requireNotNull(javaClass.classLoader?.getResourceAsStream("clientcert/$name")) { name }.use { it.readBytes() }
|
||||
|
||||
private fun failure(name: String, pass: CharArray = password, now: Date = inside): Failure {
|
||||
try {
|
||||
TrekClientIdentity.read(file(name), pass, now)
|
||||
} catch (e: TrekClientIdentity.ImportException) {
|
||||
return e.reason
|
||||
}
|
||||
fail("$name opened")
|
||||
throw AssertionError()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opensModernAndLegacyFiles() {
|
||||
for (name in listOf("valid.p12", "valid-legacy.p12")) {
|
||||
val identity = TrekClientIdentity.read(file(name), password, inside)
|
||||
assertEquals("trek-test-valid", TrekClientIdentity.commonName(identity.chain[0].subjectX500Principal))
|
||||
assertEquals("TREK mTLS Test CA", TrekClientIdentity.commonName(identity.chain[0].issuerX500Principal))
|
||||
assertEquals("RSA", identity.key.algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun namesWhatIsWrongWithAFile() {
|
||||
assertEquals(Failure.PASSWORD, failure("valid.p12", "wrong".toCharArray()))
|
||||
assertEquals(Failure.PASSWORD, failure("valid-legacy.p12", "wrong".toCharArray()))
|
||||
assertEquals(Failure.EXPIRED, failure("expired.p12"))
|
||||
assertEquals(Failure.NOT_YET_VALID, failure("future.p12"))
|
||||
assertEquals(Failure.NO_KEY, failure("cert-only.p12"))
|
||||
try {
|
||||
TrekClientIdentity.read("not a certificate".toByteArray(), password, inside)
|
||||
fail("text opened")
|
||||
} catch (e: TrekClientIdentity.ImportException) {
|
||||
assertEquals(Failure.FORMAT, e.reason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsTheCertificateOnlyToItsHostAndPort() {
|
||||
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "Trek.Example.com", -1))
|
||||
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 443))
|
||||
assertTrue(TrekClientIdentity.matches("nas.local", 8443, "nas.local", 8443))
|
||||
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "auth.example.com", 443))
|
||||
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 8443))
|
||||
assertFalse(TrekClientIdentity.matches("nas.local", 8443, "nas.local", -1))
|
||||
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, null, 443))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesForTheAddressScreenOnlyWhenTheCertificateIsWhatFails() {
|
||||
assertTrue(ClientCertWatch.refused(true, 0)) // handshake broken off: nginx, Caddy, Traefik
|
||||
assertTrue(ClientCertWatch.refused(true, 403)) // Cloudflare's block page
|
||||
assertTrue(ClientCertWatch.refused(true, 400)) // nginx without a valid certificate
|
||||
assertFalse(ClientCertWatch.refused(true, 200)) // asks, but lets everyone in
|
||||
assertFalse(ClientCertWatch.refused(true, 502)) // TREK restarting behind the proxy
|
||||
assertFalse(ClientCertWatch.refused(false, 0)) // simply offline
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsTheCommonNameOrFallsBackToTheWholeName() {
|
||||
assertEquals("Phone, home", TrekClientIdentity.commonName(X500Principal("CN=Phone\\, home,O=Family")))
|
||||
assertEquals("O=Family", TrekClientIdentity.commonName(X500Principal("O=Family")))
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,33 @@ import java.time.LocalDate
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** The native side's own decisions, apart from the views. */
|
||||
class NativeLogicTest {
|
||||
@Test
|
||||
fun aRedirectAwayWhileTheServerLoadsIsTakenAsItsSignIn() {
|
||||
val server = "https://trek.example.com"
|
||||
// Authelia in front of the page, its check let through on /api.
|
||||
assertEquals("auth.example.com", SignInRedirect.learnedHost(server, "https://auth.example.com/?rd=https%3A%2F%2Ftrek.example.com%2F", true, true, false))
|
||||
assertEquals("auth.example.com:9091", SignInRedirect.learnedHost(server, "https://Auth.example.com:9091/", true, true, false))
|
||||
// A link the user follows, a frame, or anything after the server's page has shown: the browser, as before.
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, false, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", false, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, true, true))
|
||||
// The server's own host, another scheme, or a step down to plain http.
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://trek.example.com/login", true, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "intent://auth.example.com/", true, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "http://auth.example.com/", true, true, false))
|
||||
assertEquals("auth.lan", SignInRedirect.learnedHost("http://trek.lan:3000", "http://auth.lan/", true, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(null, "https://auth.example.com/", true, true, false))
|
||||
assertTrue(SignInRedirect.onServer(server, "https://TREK.example.com/dashboard"))
|
||||
assertFalse(SignInRedirect.onServer(server, "https://auth.example.com/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pushesLandInTheChannelOfTheirEvent() {
|
||||
assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message"))
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,9 +2,15 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-action-sheet'
|
||||
project(':capacitor-action-sheet').projectDir = new File('../../node_modules/@capacitor/action-sheet/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-dialog'
|
||||
project(':capacitor-dialog').projectDir = new File('../../node_modules/@capacitor/dialog/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
|
||||
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; };
|
||||
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */; };
|
||||
896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
|
||||
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
|
||||
@@ -172,6 +173,7 @@
|
||||
10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; };
|
||||
114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; };
|
||||
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekClientIdentity.swift; sourceTree = "<group>"; };
|
||||
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -420,6 +422,7 @@
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
|
||||
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
@@ -823,6 +826,7 @@
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
|
||||
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
// Launched by a quick action on the app icon: held until the UI asks for it.
|
||||
if let item = connectionOptions.shortcutItem { _ = TrekQuickActions.open(item) }
|
||||
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
window?.rootViewController = TrekBridgeViewController()
|
||||
@@ -45,6 +47,72 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
||||
}
|
||||
|
||||
// A quick action on the app icon while the app was running in the background.
|
||||
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||
completionHandler: @escaping (Bool) -> Void) {
|
||||
completionHandler(TrekQuickActions.open(shortcutItem))
|
||||
}
|
||||
}
|
||||
|
||||
/// The quick actions on the app icon (a long press on it): "New trip" and the
|
||||
/// trip coming up, set by the UI in its language (TrekShell.setQuickActions).
|
||||
/// Each carries the path it opens; a tap goes to the UI as that path, at once
|
||||
/// when it listens, or held for it to take when the tap launched the app.
|
||||
enum TrekQuickActions {
|
||||
private static let pathKey = "path"
|
||||
private static var pending: String?
|
||||
static var onOpen: ((String) -> Void)?
|
||||
|
||||
struct Item {
|
||||
let type: String
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let icon: String
|
||||
let path: String
|
||||
|
||||
init?(raw: JSObject) {
|
||||
guard let type = raw["type"] as? String, let title = raw["title"] as? String,
|
||||
let path = raw["path"] as? String, path.hasPrefix("/") else { return nil }
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.subtitle = raw["subtitle"] as? String
|
||||
self.icon = raw["icon"] as? String ?? ""
|
||||
self.path = path
|
||||
}
|
||||
}
|
||||
|
||||
static func set(_ items: [Item]) {
|
||||
UIApplication.shared.shortcutItems = items.prefix(4).map { item in
|
||||
UIApplicationShortcutItem(
|
||||
type: "com.liketrek.trek.\(item.type)",
|
||||
localizedTitle: item.title,
|
||||
localizedSubtitle: item.subtitle,
|
||||
icon: icon(item.icon),
|
||||
userInfo: [pathKey: item.path as NSString]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func icon(_ name: String) -> UIApplicationShortcutIcon {
|
||||
switch name {
|
||||
case "add": return UIApplicationShortcutIcon(type: .add)
|
||||
case "trip": return UIApplicationShortcutIcon(systemImageName: "suitcase")
|
||||
default: return UIApplicationShortcutIcon(type: .compose)
|
||||
}
|
||||
}
|
||||
|
||||
static func open(_ item: UIApplicationShortcutItem) -> Bool {
|
||||
guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false }
|
||||
if let onOpen { onOpen(path) } else { pending = path }
|
||||
return true
|
||||
}
|
||||
|
||||
/// The action the app was launched with, once.
|
||||
static func take() -> String? {
|
||||
defer { pending = nil }
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
/// The blur over the app while it sits in the app switcher, when the app lock
|
||||
|
||||
@@ -43,12 +43,32 @@ 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
|
||||
// draws its own (mobile/components/MEdgeBack.tsx, feature "edgeBack"):
|
||||
// the page follows the finger and an open sheet closes first.
|
||||
webView?.allowsBackForwardNavigationGestures = false
|
||||
// A long press on a link would lift a preview of the page it points to,
|
||||
// which no native list does; the press means "drag" or nothing here.
|
||||
webView?.allowsLinkPreview = 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. Always in place: a sign-in host can also be learned
|
||||
/// while the app runs (TrekSignInRedirect).
|
||||
private func guardBridgeMessages() {
|
||||
guard 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
|
||||
@@ -137,14 +157,63 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps one more sign-in host for the remembered server, as the web view met it (TrekSignInRedirect).
|
||||
static func addSignInHost(_ host: String) {
|
||||
var hosts = signInHosts
|
||||
guard hosts.insert(host.lowercased()).inserted else { return }
|
||||
signInHosts = hosts
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// The one-time code of a TREK Cloud purchase (setServer's `welcome`). The
|
||||
/// server's first page opens its welcome page with it once, which signs the
|
||||
/// buyer in (TrekNavigationProxy). Kept in memory only, never on disk.
|
||||
static var pendingWelcome: String?
|
||||
|
||||
/// The welcome page for the pending code, handed out once.
|
||||
static func takeWelcomeURL() -> URL? {
|
||||
guard let code = pendingWelcome, let server = url else { return nil }
|
||||
pendingWelcome = nil
|
||||
let base = server.hasSuffix("/") ? server : server + "/"
|
||||
return URL(string: base + "api/managed/welcome#" + code)
|
||||
}
|
||||
|
||||
static var offlineCopyURL: URL {
|
||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
return support.appendingPathComponent("offline-start-page.html")
|
||||
@@ -366,12 +435,44 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sign-in page in front of the server that the address screen never saw.
|
||||
/// A proxy can let the app's check through (an Authelia or Authentik rule that
|
||||
/// bypasses /api) and still send the page itself to its sign-in. Handed to
|
||||
/// Safari, that sign-in leaves its session there and the app stays blank on
|
||||
/// every launch. So a redirect away from the server, while the server's first
|
||||
/// page is still loading, is taken as the proxy's sign-in: it loads in the web
|
||||
/// view and its host is kept, the way the address screen keeps the ones it saw.
|
||||
/// A link the user taps is no redirect, and once a page of the server has
|
||||
/// shown, nothing is learned any more.
|
||||
enum TrekSignInRedirect {
|
||||
/// The host (host:port with an explicit port) to keep as a sign-in host, or nil.
|
||||
static func learnedHost(server: String?, target: URL?, mainFrame: Bool, redirect: Bool, serverShown: Bool) -> String? {
|
||||
guard mainFrame, redirect, !serverShown,
|
||||
let server, let from = URL(string: server), let fromHost = from.host?.lowercased(),
|
||||
let target, let toHost = target.host?.lowercased(),
|
||||
let toScheme = target.scheme?.lowercased(), ["https", "http"].contains(toScheme) else { return nil }
|
||||
// Never from https down to plain http: the sign-in would send a password in the clear.
|
||||
if from.scheme?.lowercased() == "https", toScheme != "https" { return nil }
|
||||
guard toHost != fromHost else { return nil }
|
||||
if let port = target.port { return "\(toHost):\(port)" }
|
||||
return toHost
|
||||
}
|
||||
|
||||
/// Whether `url` is on the server's host, where the app's own pages come from.
|
||||
static func onServer(server: String?, url: URL?) -> Bool {
|
||||
guard let server, let host = URL(string: server)?.host?.lowercased() else { return false }
|
||||
return url?.host?.lowercased() == host
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in front of Capacitor's navigation delegate and hands it everything
|
||||
/// but the failures of a navigation that another one replaced: a cancelled
|
||||
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
|
||||
/// are no fault, and Capacitor would answer them with its error page.
|
||||
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
private let inner: WKNavigationDelegate
|
||||
/// A page of the server has shown since launch; from then on no sign-in host is learned (TrekSignInRedirect).
|
||||
private var serverShown = false
|
||||
|
||||
init(inner: WKNavigationDelegate) {
|
||||
self.inner = inner
|
||||
@@ -391,6 +492,88 @@ 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
|
||||
}
|
||||
// WebKit asks again for every server redirect, as a navigation of type .other.
|
||||
if let host = TrekSignInRedirect.learnedHost(
|
||||
server: TrekServerStore.url,
|
||||
target: navigationAction.request.url,
|
||||
mainFrame: navigationAction.targetFrame?.isMainFrame == true,
|
||||
redirect: navigationAction.navigationType == .other,
|
||||
serverShown: serverShown
|
||||
) {
|
||||
TrekServerStore.addSignInHost(host)
|
||||
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)
|
||||
}
|
||||
|
||||
/// After the server's first page, a pending welcome code opens the welcome
|
||||
/// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
inner.webView?(webView, didFinish: navigation)
|
||||
if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) { serverShown = true }
|
||||
if let welcome = TrekServerStore.takeWelcomeURL() {
|
||||
webView.load(URLRequest(url: welcome))
|
||||
}
|
||||
}
|
||||
|
||||
/// A proxy in front of the server asks for a client certificate (mutual TLS):
|
||||
/// the stored one goes to the host and port it was imported for and nowhere
|
||||
/// else (TrekClientIdentity). Every other challenge is Capacitor's as before.
|
||||
func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
if TrekClientIdentity.answer(challenge, completionHandler) {
|
||||
let space = challenge.protectionSpace
|
||||
TrekClientCertWatch.serverAsked(host: space.host, port: space.port) { [weak self, weak webView] in
|
||||
guard let self, let webView else { return }
|
||||
// Capacitor's own answer to a failed load: the address screen (errorPath).
|
||||
self.inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRejected))
|
||||
}
|
||||
return
|
||||
}
|
||||
let selector = NSSelectorFromString("webView:didReceiveAuthenticationChallenge:completionHandler:")
|
||||
guard (inner as AnyObject).responds(to: selector) else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
inner.webView?(webView, didReceive: challenge, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
/// A proxy that asked the page for a client certificate, got none and
|
||||
/// answered with its own refusal (Cloudflare's 403, nginx's 400) sends the
|
||||
/// app to the address screen, which says so and offers to add one, rather
|
||||
/// than leaving the proxy's error page up.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse,
|
||||
http.statusCode >= 400, http.statusCode < 500, let asked = TrekClientIdentity.lastRequest, !asked.served,
|
||||
let url = http.url, url.host?.lowercased() == asked.host, (url.port ?? 443) == asked.port {
|
||||
decisionHandler(.cancel)
|
||||
inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRequired))
|
||||
return
|
||||
}
|
||||
let selector = NSSelectorFromString("webView:decidePolicyForNavigationResponse:decisionHandler:")
|
||||
guard (inner as AnyObject).responds(to: selector) else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
inner.webView?(webView, decidePolicyFor: navigationResponse, 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 +584,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// The client certificate a user's TREK server asks for (mutual TLS): a proxy
|
||||
/// such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through
|
||||
/// only devices that show one. The app holds at most one, for one server.
|
||||
///
|
||||
/// It comes in as a PKCS#12 file (.p12, .pfx) and its password. The password is
|
||||
/// never kept; the identity goes into the keychain, this device only, readable
|
||||
/// after the first unlock (so a background refresh can show it too). Only the
|
||||
/// host and port it was imported for ever see it (`identity(forHost:port:)`), so a
|
||||
/// sign-in page, a map tile provider or liketrek.com never learns of it.
|
||||
enum TrekClientIdentity {
|
||||
private static let label = "trek-client-identity"
|
||||
private static let hostKey = "TrekClientIdentityHost"
|
||||
private static let portKey = "TrekClientIdentityPort"
|
||||
|
||||
enum Failure: String, Error {
|
||||
case password, format, noKey = "no_key", expired, notYetValid = "not_yet_valid", store
|
||||
}
|
||||
|
||||
/// The last server that asked for a certificate while the app ran, and whether it got one.
|
||||
struct Request {
|
||||
let host: String
|
||||
let port: Int
|
||||
let served: Bool
|
||||
}
|
||||
|
||||
private static let lock = NSLock()
|
||||
private static var _lastRequest: Request?
|
||||
static var lastRequest: Request? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _lastRequest
|
||||
}
|
||||
|
||||
static func noteRequest(host: String, port: Int, served: Bool) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
_lastRequest = Request(host: host.lowercased(), port: port > 0 ? port : 443, served: served)
|
||||
}
|
||||
|
||||
// MARK: Reading a file
|
||||
|
||||
/// Opens a PKCS#12 file: the identity and the rest of its chain.
|
||||
static func read(_ data: Data, password: String, now: Date = Date()) -> Result<(SecIdentity, [SecCertificate]), Failure> {
|
||||
var items: CFArray?
|
||||
let status = SecPKCS12Import(data as CFData, [kSecImportExportPassphrase as String: password] as CFDictionary, &items)
|
||||
switch status {
|
||||
case errSecSuccess: break
|
||||
case errSecAuthFailed, errSecPkcs12VerifyFailure: return .failure(.password)
|
||||
default: return .failure(.format)
|
||||
}
|
||||
guard let first = (items as? [[String: Any]])?.first,
|
||||
let value = first[kSecImportItemIdentity as String] else { return .failure(.noKey) }
|
||||
let identity = value as! SecIdentity
|
||||
var leaf: SecCertificate?
|
||||
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return .failure(.noKey) }
|
||||
if let validity = validity(of: leaf) {
|
||||
if now > validity.notAfter { return .failure(.expired) }
|
||||
if now < validity.notBefore { return .failure(.notYetValid) }
|
||||
}
|
||||
let leafData = SecCertificateCopyData(leaf) as Data
|
||||
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate] ?? []).filter {
|
||||
SecCertificateCopyData($0) as Data != leafData
|
||||
}
|
||||
return .success((identity, chain))
|
||||
}
|
||||
|
||||
// MARK: Keeping it
|
||||
|
||||
/// Keeps the identity for this host and port, replacing any other.
|
||||
static func save(_ identity: SecIdentity, chain: [SecCertificate], host: String, port: Int) -> Bool {
|
||||
remove()
|
||||
let query: [String: Any] = [
|
||||
kSecValueRef as String: identity,
|
||||
kSecAttrLabel as String: label,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { return false }
|
||||
// The chain's intermediates go along so a proxy that wants them gets them.
|
||||
let encoded = chain.map { SecCertificateCopyData($0) as Data }
|
||||
UserDefaults.standard.set(encoded, forKey: chainKey)
|
||||
UserDefaults.standard.set(host.lowercased(), forKey: hostKey)
|
||||
UserDefaults.standard.set(port, forKey: portKey)
|
||||
return true
|
||||
}
|
||||
|
||||
private static let chainKey = "TrekClientIdentityChain"
|
||||
|
||||
static func remove() {
|
||||
TrekClientCertWatch.reset()
|
||||
for itemClass in [kSecClassIdentity, kSecClassCertificate, kSecClassKey] {
|
||||
SecItemDelete([kSecClass as String: itemClass, kSecAttrLabel as String: label] as CFDictionary)
|
||||
}
|
||||
UserDefaults.standard.removeObject(forKey: hostKey)
|
||||
UserDefaults.standard.removeObject(forKey: portKey)
|
||||
UserDefaults.standard.removeObject(forKey: chainKey)
|
||||
}
|
||||
|
||||
private static func storedIdentity() -> SecIdentity? {
|
||||
var item: CFTypeRef?
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassIdentity,
|
||||
kSecAttrLabel as String: label,
|
||||
kSecReturnRef as String: true,
|
||||
]
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item else { return nil }
|
||||
return (item as! SecIdentity)
|
||||
}
|
||||
|
||||
/// Whether a request to host and port (0 or less for https' default) goes to the server the identity is for.
|
||||
static func matches(host: String?, port: Int) -> Bool {
|
||||
guard let host, let stored = UserDefaults.standard.string(forKey: hostKey) else { return false }
|
||||
let storedPort = UserDefaults.standard.integer(forKey: portKey)
|
||||
return host.lowercased() == stored && (port > 0 ? port : 443) == (storedPort > 0 ? storedPort : 443)
|
||||
}
|
||||
|
||||
/// The credential for a server at host and port, or nil when the stored one is for another.
|
||||
static func credential(forHost host: String, port: Int) -> URLCredential? {
|
||||
guard matches(host: host, port: port), let identity = storedIdentity() else { return nil }
|
||||
let chain = (UserDefaults.standard.array(forKey: chainKey) as? [Data] ?? []).compactMap {
|
||||
SecCertificateCreateWithData(nil, $0 as CFData)
|
||||
}
|
||||
// Per session: a replaced certificate takes effect on the next connection.
|
||||
return URLCredential(identity: identity, certificates: chain.isEmpty ? nil : chain, persistence: .forSession)
|
||||
}
|
||||
|
||||
/// Answers a challenge if it asks for a client certificate; false leaves it to the caller.
|
||||
static func answer(_ challenge: URLAuthenticationChallenge, _ completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Bool {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate else { return false }
|
||||
let space = challenge.protectionSpace
|
||||
let credential = credential(forHost: space.host, port: space.port)
|
||||
noteRequest(host: space.host, port: space.port, served: credential != nil)
|
||||
if let credential {
|
||||
completion(.useCredential, credential)
|
||||
} else {
|
||||
// As a browser without a matching certificate: go on without one.
|
||||
completion(.performDefaultHandling, nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: What the settings show
|
||||
|
||||
static func info() -> [String: Any]? {
|
||||
guard let host = UserDefaults.standard.string(forKey: hostKey), let identity = storedIdentity() else { return nil }
|
||||
var leaf: SecCertificate?
|
||||
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return nil }
|
||||
let port = UserDefaults.standard.integer(forKey: portKey)
|
||||
var result: [String: Any] = [
|
||||
"host": host,
|
||||
"port": port > 0 ? port : 443,
|
||||
"subject": SecCertificateCopySubjectSummary(leaf) as String? ?? "",
|
||||
"issuer": issuerName(of: leaf) ?? "",
|
||||
]
|
||||
if let validity = validity(of: leaf) { result["notAfter"] = Int(validity.notAfter.timeIntervalSince1970 * 1000) }
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: Certificate fields
|
||||
|
||||
/// notBefore and notAfter from the DER: iOS has no public API for them.
|
||||
static func validity(of certificate: SecCertificate) -> (notBefore: Date, notAfter: Date)? {
|
||||
DER.validity(SecCertificateCopyData(certificate) as Data)
|
||||
}
|
||||
|
||||
private static func issuerName(of certificate: SecCertificate) -> String? {
|
||||
DER.issuerCommonName(SecCertificateCopyData(certificate) as Data)
|
||||
}
|
||||
|
||||
/// Just enough DER to read an X.509 certificate's validity and a name's CN.
|
||||
enum DER {
|
||||
private struct Node {
|
||||
let tag: UInt8
|
||||
let body: Range<Int>
|
||||
let end: Int
|
||||
}
|
||||
|
||||
private static func node(_ bytes: [UInt8], at offset: Int) -> Node? {
|
||||
guard offset + 2 <= bytes.count else { return nil }
|
||||
let tag = bytes[offset]
|
||||
var length = Int(bytes[offset + 1])
|
||||
var start = offset + 2
|
||||
if length & 0x80 != 0 {
|
||||
let count = length & 0x7f
|
||||
guard count > 0, count <= 4, start + count <= bytes.count else { return nil }
|
||||
length = bytes[start..<start + count].reduce(0) { $0 << 8 | Int($1) }
|
||||
start += count
|
||||
}
|
||||
guard start + length <= bytes.count else { return nil }
|
||||
return Node(tag: tag, body: start..<start + length, end: start + length)
|
||||
}
|
||||
|
||||
private static func children(_ bytes: [UInt8], in range: Range<Int>) -> [Node] {
|
||||
var result: [Node] = []
|
||||
var offset = range.lowerBound
|
||||
while offset < range.upperBound, let child = node(bytes, at: offset) {
|
||||
result.append(child)
|
||||
offset = child.end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// The fields of the TBSCertificate after the version: serial, signature algorithm, issuer, validity, ...
|
||||
private static func tbsFields(_ bytes: [UInt8]) -> [Node]? {
|
||||
guard let certificate = node(bytes, at: 0), let tbs = children(bytes, in: certificate.body).first else { return nil }
|
||||
var fields = children(bytes, in: tbs.body)
|
||||
if fields.first?.tag == 0xa0 { fields.removeFirst() }
|
||||
return fields.count > 3 ? fields : nil
|
||||
}
|
||||
|
||||
static func validity(_ data: Data) -> (notBefore: Date, notAfter: Date)? {
|
||||
let bytes = [UInt8](data)
|
||||
guard let fields = tbsFields(bytes) else { return nil }
|
||||
let times = children(bytes, in: fields[3].body)
|
||||
guard times.count == 2, let from = time(bytes, times[0]), let to = time(bytes, times[1]) else { return nil }
|
||||
return (from, to)
|
||||
}
|
||||
|
||||
private static func time(_ bytes: [UInt8], _ node: Node) -> Date? {
|
||||
guard let text = String(bytes: bytes[node.body], encoding: .ascii) else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||
formatter.dateFormat = node.tag == 0x17 ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
|
||||
return formatter.date(from: text)
|
||||
}
|
||||
|
||||
/// The CN of the issuer's Name (a SEQUENCE of SETs of type and value), or nil.
|
||||
static func issuerCommonName(_ data: Data) -> String? {
|
||||
let bytes = [UInt8](data)
|
||||
guard let fields = tbsFields(bytes) else { return nil }
|
||||
for set in children(bytes, in: fields[2].body) {
|
||||
for pair in children(bytes, in: set.body) {
|
||||
let parts = children(bytes, in: pair.body)
|
||||
guard parts.count == 2, Array(bytes[parts[0].body]) == [0x55, 0x04, 0x03] else { continue }
|
||||
return String(bytes: bytes[parts[1].body], encoding: .utf8)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notices when the user's server stops letting the app in for want of a
|
||||
/// client certificate (one removed, expired, or no longer trusted). The page can
|
||||
/// still be the stored offline copy while every request fails, so when the server
|
||||
/// asks for a certificate the app checks once natively whether TREK answers with
|
||||
/// what it holds. Only a check that fails at the certificate (the handshake broke
|
||||
/// off, or the proxy's own 4xx) calls `onRefused`; a server that merely asks and
|
||||
/// lets everyone in answers TREK's 200. At most once a minute.
|
||||
enum TrekClientCertWatch {
|
||||
private static let lock = NSLock()
|
||||
private static var lastCheck = Date.distantPast
|
||||
|
||||
/// A new or removed certificate is worth a check of its own, whenever the last one ran.
|
||||
static func reset() {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
lastCheck = .distantPast
|
||||
}
|
||||
|
||||
static func serverAsked(host: String, port: Int, onRefused: @escaping () -> Void) {
|
||||
guard let raw = TrekServerStore.url, let server = URL(string: raw), server.scheme == "https",
|
||||
server.host?.lowercased() == host.lowercased(), (server.port ?? 443) == (port > 0 ? port : 443),
|
||||
let features = URL(string: raw.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/health/features") else { return }
|
||||
lock.lock()
|
||||
let due = Date().timeIntervalSince(lastCheck) >= 60
|
||||
if due { lastCheck = Date() }
|
||||
lock.unlock()
|
||||
guard due else { return }
|
||||
var request = URLRequest(url: features, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
var task: URLSessionDataTask?
|
||||
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { _, response, _ in
|
||||
let asked = task.flatMap { TrekClientCertSessionDelegate.shared.ask(for: $0) } != nil
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if asked && (status == 0 || (400..<500).contains(status)) {
|
||||
DispatchQueue.main.async(execute: onRefused)
|
||||
}
|
||||
}
|
||||
task?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// A URLSession delegate for the app's own requests to the user's server: it
|
||||
/// shows the client certificate where the server asks for it and notes the ask.
|
||||
final class TrekClientCertSessionDelegate: NSObject, URLSessionTaskDelegate {
|
||||
static let shared = TrekClientCertSessionDelegate()
|
||||
|
||||
/// The app's session for the user's server (offline copy, watch, address check).
|
||||
static let session = URLSession(configuration: .default, delegate: shared, delegateQueue: nil)
|
||||
|
||||
/// Per task: whether the server asked, and whether it got a certificate (address check only).
|
||||
private let lock = NSLock()
|
||||
private var asks: [Int: Bool] = [:]
|
||||
|
||||
func ask(for task: URLSessionTask) -> Bool? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return asks.removeValue(forKey: task.taskIdentifier)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
let handled = TrekClientIdentity.answer(challenge) { disposition, credential in
|
||||
self.lock.lock()
|
||||
self.asks[task.taskIdentifier] = credential != nil
|
||||
self.lock.unlock()
|
||||
completionHandler(disposition, credential)
|
||||
}
|
||||
if !handled { completionHandler(.performDefaultHandling, nil) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The file picker and password prompt of an import, in the UI's words.
|
||||
final class TrekClientCertImporter: NSObject, UIDocumentPickerDelegate {
|
||||
private let presenter: UIViewController
|
||||
private let labels: [String: String]
|
||||
private let host: String
|
||||
private let port: Int
|
||||
private let done: ([String: Any]) -> Void
|
||||
private var keepAlive: TrekClientCertImporter?
|
||||
|
||||
init(presenter: UIViewController, labels: [String: String], host: String, port: Int, done: @escaping ([String: Any]) -> Void) {
|
||||
self.presenter = presenter
|
||||
self.labels = labels
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.done = done
|
||||
}
|
||||
|
||||
func start() {
|
||||
keepAlive = self
|
||||
let types = [UTType(filenameExtension: "p12"), UTType(filenameExtension: "pfx"), UTType("com.rsa.pkcs-12")].compactMap { $0 }
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types.isEmpty ? [.data] : types, asCopy: true)
|
||||
picker.delegate = self
|
||||
presenter.present(picker, animated: true)
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
finish(["result": "cancelled"])
|
||||
}
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let url = urls.first,
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
(attributes[.size] as? Int ?? 0) <= 256 * 1024,
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
finish(["result": "failed", "failure": TrekClientIdentity.Failure.format.rawValue])
|
||||
return
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
askPassword(for: data, wrong: false)
|
||||
}
|
||||
|
||||
private func askPassword(for data: Data, wrong: Bool) {
|
||||
let alert = UIAlertController(title: labels["title"], message: wrong ? labels["wrongPassword"] : labels["message"], preferredStyle: .alert)
|
||||
alert.addTextField { field in
|
||||
field.isSecureTextEntry = true
|
||||
field.placeholder = self.labels["placeholder"]
|
||||
field.textContentType = .password
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: labels["cancel"] ?? "Cancel", style: .cancel) { _ in
|
||||
self.finish(["result": "cancelled"])
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: labels["ok"] ?? "OK", style: .default) { _ in
|
||||
self.open(data, password: alert.textFields?.first?.text ?? "")
|
||||
})
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func open(_ data: Data, password: String) {
|
||||
switch TrekClientIdentity.read(data, password: password) {
|
||||
case .failure(.password):
|
||||
askPassword(for: data, wrong: true)
|
||||
case .failure(let failure):
|
||||
finish(["result": "failed", "failure": failure.rawValue])
|
||||
case .success(let (identity, chain)):
|
||||
guard TrekClientIdentity.save(identity, chain: chain, host: host, port: port) else {
|
||||
finish(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
|
||||
return
|
||||
}
|
||||
var result: [String: Any] = ["result": "imported"]
|
||||
if let info = TrekClientIdentity.info() { result["certificate"] = info }
|
||||
finish(result)
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: [String: Any]) {
|
||||
done(result)
|
||||
keepAlive = nil
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,12 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setQuickActions", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "takeQuickAction", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "requestServer", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// What this binary offers the server's UI, raised with every native feature
|
||||
@@ -60,7 +66,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
|
||||
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
|
||||
static var features: [String] {
|
||||
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands"]
|
||||
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate"]
|
||||
if canAddWalletPasses { names.append("wallet") }
|
||||
if TrekWatchLink.isAvailable { names.append("watch") }
|
||||
return names
|
||||
@@ -85,6 +91,31 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
TrekKeyCommands.onPress = { [weak self] id in
|
||||
self?.notifyListeners("keyCommand", data: ["id": id])
|
||||
}
|
||||
TrekQuickActions.onOpen = { [weak self] path in
|
||||
DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path]) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quick actions on the app icon
|
||||
|
||||
/// The app icon's quick actions, in the UI's language (TrekQuickActions).
|
||||
@objc func setQuickActions(_ call: CAPPluginCall) {
|
||||
let items = (call.getArray("items", JSObject.self) ?? []).compactMap { TrekQuickActions.Item(raw: $0) }
|
||||
DispatchQueue.main.async {
|
||||
TrekQuickActions.set(items)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/// The quick action the app was launched with, before the UI was there to hear of it.
|
||||
@objc func takeQuickAction(_ call: CAPPluginCall) {
|
||||
DispatchQueue.main.async {
|
||||
if let path = TrekQuickActions.take() {
|
||||
call.resolve(["path": path])
|
||||
} else {
|
||||
call.resolve(["path": NSNull()])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard
|
||||
@@ -324,6 +355,12 @@ 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() })
|
||||
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
|
||||
let welcome = call.getString("welcome") ?? ""
|
||||
TrekServerStore.pendingWelcome = welcome.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil ? welcome : nil
|
||||
call.resolve()
|
||||
restartBridge()
|
||||
}
|
||||
@@ -396,7 +433,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
}
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||
URLSession.shared.dataTask(with: request) { data, response, _ in
|
||||
// The app's session, so a server behind mutual TLS gets the client certificate.
|
||||
TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, _ in
|
||||
guard let data, let http = response as? HTTPURLResponse, http.statusCode == 200,
|
||||
http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/html") == true,
|
||||
TrekServerStore.url == raw else {
|
||||
@@ -435,6 +473,86 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Client certificates (mutual TLS)
|
||||
|
||||
extension TrekShellPlugin {
|
||||
/// The address screen's check of a server, made natively so it notices
|
||||
/// whether the server asks for a client certificate and can show the stored
|
||||
/// one. A failed connection resolves with status 0 instead of rejecting.
|
||||
@objc func requestServer(_ call: CAPPluginCall) {
|
||||
guard let raw = call.getString("url"), let url = URL(string: raw),
|
||||
["https", "http"].contains(url.scheme?.lowercased() ?? ""), url.host != nil else {
|
||||
call.reject("A http(s) URL is required")
|
||||
return
|
||||
}
|
||||
let timeout = min(max(Double(call.getInt("timeoutMs") ?? 10000), 1000), 30000) / 1000
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
|
||||
request.setValue("application/json, text/html;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept")
|
||||
let delegate = TrekClientCertSessionDelegate.shared
|
||||
var task: URLSessionDataTask?
|
||||
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, error in
|
||||
let asked = task.flatMap { delegate.ask(for: $0) }
|
||||
var result: [String: Any] = [
|
||||
"certRequested": asked != nil,
|
||||
"certSent": asked == true,
|
||||
"url": response?.url?.absoluteString ?? raw,
|
||||
]
|
||||
if asked != nil, let host = url.host?.lowercased() { result["certHost"] = "\(host):\(url.port ?? 443)" }
|
||||
if let http = response as? HTTPURLResponse {
|
||||
result["status"] = http.statusCode
|
||||
let body = (data ?? Data()).prefix(512 * 1024)
|
||||
result["data"] = String(data: body, encoding: .utf8) ?? ""
|
||||
var headers: [String: String] = [:]
|
||||
for (name, value) in http.allHeaderFields {
|
||||
if let name = name as? String, let value = value as? String { headers[name] = value }
|
||||
}
|
||||
result["headers"] = headers
|
||||
} else {
|
||||
result["status"] = 0
|
||||
result["data"] = ""
|
||||
if let error { result["error"] = (error as NSError).domain + ":" + String((error as NSError).code) }
|
||||
}
|
||||
call.resolve(result)
|
||||
}
|
||||
task?.resume()
|
||||
}
|
||||
|
||||
/// The stored client certificate's details, and the last server that asked for one.
|
||||
@objc func clientCertificate(_ call: CAPPluginCall) {
|
||||
var result: [String: Any] = [:]
|
||||
if let info = TrekClientIdentity.info() { result["certificate"] = info } else { result["certificate"] = NSNull() }
|
||||
if let asked = TrekClientIdentity.lastRequest {
|
||||
result["request"] = ["host": asked.host, "port": asked.port, "served": asked.served]
|
||||
}
|
||||
call.resolve(result)
|
||||
}
|
||||
|
||||
@objc func removeClientCertificate(_ call: CAPPluginCall) {
|
||||
TrekClientIdentity.remove()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/// Picks a .p12/.pfx file, asks for its password until it opens or the user
|
||||
/// gives up, and keeps it for the server at host and port.
|
||||
@objc func importClientCertificate(_ call: CAPPluginCall) {
|
||||
guard let host = call.getString("host"), host.range(of: "^[A-Za-z0-9.-]+$", options: .regularExpression) != nil else {
|
||||
call.reject("A host is required")
|
||||
return
|
||||
}
|
||||
let port = call.getInt("port") ?? 443
|
||||
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let presenter = self?.bridge?.viewController else {
|
||||
call.resolve(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
|
||||
return
|
||||
}
|
||||
TrekClientCertImporter(presenter: presenter, labels: labels, host: host, port: port) { result in
|
||||
call.resolve(result)
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
|
||||
/// When-In-Use permission asked the first time.
|
||||
// MARK: - Apple Wallet
|
||||
|
||||
@@ -237,7 +237,7 @@ enum TrekWatchWriter {
|
||||
for (field, value) in HTTPCookie.requestHeaderFields(with: session) {
|
||||
signed.setValue(value, forHTTPHeaderField: field)
|
||||
}
|
||||
URLSession.shared.dataTask(with: signed) { data, response, error in
|
||||
TrekClientCertSessionDelegate.session.dataTask(with: signed) { data, response, error in
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
completion(data, error == nil && (200..<300).contains(status))
|
||||
}.resume()
|
||||
|
||||
@@ -12,7 +12,9 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"),
|
||||
.package(name: "CapacitorActionSheet", path: "../../../../node_modules/@capacitor/action-sheet"),
|
||||
.package(name: "CapacitorApp", path: "../../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorDialog", path: "../../../../node_modules/@capacitor/dialog"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorHaptics", path: "../../../../node_modules/@capacitor/haptics"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../../node_modules/@capacitor/keyboard"),
|
||||
@@ -26,7 +28,9 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorActionSheet", package: "CapacitorActionSheet"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorDialog", package: "CapacitorDialog"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
|
||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<title>TREK</title>
|
||||
|
||||
+348
-35
@@ -1,3 +1,4 @@
|
||||
import { App } from '@capacitor/app';
|
||||
import { Capacitor, CapacitorHttp } from '@capacitor/core';
|
||||
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
|
||||
import { SplashScreen } from '@capacitor/splash-screen';
|
||||
@@ -5,11 +6,27 @@ 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 { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
|
||||
import { TrekShell } from '../src/native/trekShell';
|
||||
import {
|
||||
AUTH_PROXY_DOCS_URL,
|
||||
CLIENT_CERT_DOCS_URL,
|
||||
CLOUD_TERMS_URL,
|
||||
CLOUD_URL,
|
||||
PRIVACY_URL,
|
||||
SERVER_DOCS_URL,
|
||||
STORE_TERMS_URL,
|
||||
} from '../src/native/links';
|
||||
import {
|
||||
probeDemo,
|
||||
probeServer,
|
||||
type ClientCertDemand,
|
||||
type ProbeFailure,
|
||||
type ProbeGet,
|
||||
} from '../src/native/serverAddress';
|
||||
import { passwordProblem } from '../../shared/src/auth/password-policy';
|
||||
import { TrekShell, type ClientCertificateFailure } from '../src/native/trekShell';
|
||||
import { directionOf, pickLocale, translator } from './i18n';
|
||||
import './shell.css';
|
||||
|
||||
@@ -47,11 +64,34 @@ function cloudSuffix(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON bodies arrive as text from the app's own request; everything else stays text. */
|
||||
function parseBody(data: unknown): unknown {
|
||||
if (typeof data !== 'string') return data;
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// Native HTTP rather than fetch: the probe goes to a server of the user's
|
||||
// choosing, which has no reason to answer this page's origin with CORS.
|
||||
// choosing, which has no reason to answer this page's origin with CORS. The
|
||||
// app's own request comes first: it shows a stored client certificate and
|
||||
// reports whether the server asked for one. A web preview has only Capacitor's.
|
||||
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 };
|
||||
try {
|
||||
const response = await TrekShell.requestServer({ url, timeoutMs: PROBE_TIMEOUT_MS });
|
||||
return {
|
||||
status: response.status,
|
||||
data: parseBody(response.data),
|
||||
url: response.url,
|
||||
headers: response.headers,
|
||||
clientCert: { requested: response.certRequested, sent: response.certSent },
|
||||
};
|
||||
} catch {
|
||||
const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS });
|
||||
return { status: response.status, data: response.data, url: response.url, headers: response.headers };
|
||||
}
|
||||
};
|
||||
|
||||
function failureMessage(reason: ProbeFailure): string {
|
||||
@@ -62,6 +102,10 @@ 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 'clientCert':
|
||||
return t('native.cert.title');
|
||||
case 'tooOld':
|
||||
return t('native.connect.errorTooOld');
|
||||
}
|
||||
@@ -251,24 +295,36 @@ 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, cert?: ClientCertDemand): 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 if (reason === 'clientCert' && cert) {
|
||||
error.textContent = '';
|
||||
fillCertPanel(gatePanel, cert, () => form.requestSubmit());
|
||||
} else {
|
||||
error.textContent = failureMessage(reason);
|
||||
}
|
||||
input.disabled = false;
|
||||
submit.disabled = false;
|
||||
label.textContent = t('native.connect.submit');
|
||||
submit.replaceChildren(label);
|
||||
input.focus();
|
||||
// A panel with its own next step stays in view; the keyboard would cover it.
|
||||
if (gatePanel.hidden) input.focus();
|
||||
};
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
void tap(ImpactStyle.Medium);
|
||||
error.textContent = '';
|
||||
gatePanel.hidden = true;
|
||||
gatePanel.replaceChildren();
|
||||
input.disabled = true;
|
||||
submit.disabled = true;
|
||||
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'));
|
||||
@@ -277,7 +333,7 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
await connected(result.url);
|
||||
return;
|
||||
}
|
||||
fail(result.reason);
|
||||
fail(result.reason, result.gate, result.url, result.cert);
|
||||
});
|
||||
|
||||
show(
|
||||
@@ -298,6 +354,124 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A proxy in front of the server lets only devices with a client certificate
|
||||
* through (mutual TLS: Cloudflare, nginx, Caddy, Traefik). The panel offers to
|
||||
* add the certificate file for it, or another one when the proxy refused the
|
||||
* one shown; `retry` checks the server again once it is added.
|
||||
*/
|
||||
function fillCertPanel(panel: HTMLElement, cert: ClientCertDemand, retry: () => void): void {
|
||||
const status = el('p', { className: 'gate-note', role: 'status' });
|
||||
const choose = el(
|
||||
'button',
|
||||
{ type: 'button', className: 'btn' },
|
||||
cert.sent ? t('native.cert.chooseOther') : t('native.cert.choose')
|
||||
);
|
||||
choose.addEventListener('click', async () => {
|
||||
void tap(ImpactStyle.Medium);
|
||||
choose.disabled = true;
|
||||
status.textContent = '';
|
||||
const outcome = await importCertificate(cert.host, cert.port);
|
||||
choose.disabled = false;
|
||||
if (outcome === 'imported') {
|
||||
void notify(NotificationType.Success);
|
||||
retry();
|
||||
} else if (outcome !== 'cancelled') {
|
||||
void notify(NotificationType.Error);
|
||||
status.textContent = certFailureMessage(outcome);
|
||||
}
|
||||
});
|
||||
panel.replaceChildren(
|
||||
el('p', { className: 'gate-title' }, cert.sent ? t('native.cert.titleRefused') : t('native.cert.title')),
|
||||
el(
|
||||
'p',
|
||||
{ className: 'gate-text' },
|
||||
cert.sent ? t('native.cert.hintRefused') : t('native.cert.hint', { host: cert.host })
|
||||
),
|
||||
choose,
|
||||
status,
|
||||
externalLink(t('native.cert.help'), CLIENT_CERT_DOCS_URL)
|
||||
);
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
/** Imports a certificate file for host and port: 'imported', 'cancelled', or why it failed. */
|
||||
async function importCertificate(
|
||||
host: string,
|
||||
port: number
|
||||
): Promise<'imported' | 'cancelled' | ClientCertificateFailure> {
|
||||
try {
|
||||
const result = await TrekShell.importClientCertificate({
|
||||
host,
|
||||
port,
|
||||
labels: {
|
||||
title: t('native.cert.passwordTitle'),
|
||||
message: t('native.cert.passwordMessage'),
|
||||
placeholder: t('native.cert.passwordPlaceholder'),
|
||||
ok: t('native.cert.passwordOk'),
|
||||
cancel: t('native.cert.passwordCancel'),
|
||||
wrongPassword: t('native.cert.passwordWrong'),
|
||||
},
|
||||
});
|
||||
if (result.result === 'failed') return result.failure ?? 'format';
|
||||
return result.result;
|
||||
} catch {
|
||||
return 'store';
|
||||
}
|
||||
}
|
||||
|
||||
function certFailureMessage(failure: ClientCertificateFailure): string {
|
||||
switch (failure) {
|
||||
case 'no_key':
|
||||
return t('native.cert.errorNoKey');
|
||||
case 'expired':
|
||||
return t('native.cert.errorExpired');
|
||||
case 'not_yet_valid':
|
||||
return t('native.cert.errorNotYetValid');
|
||||
case 'store':
|
||||
return t('native.cert.errorStore');
|
||||
case 'format':
|
||||
return t('native.cert.errorFormat');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Get TREK Cloud ──────────────────────────────────────────────────────────
|
||||
function feature(path: string, text: string): HTMLElement {
|
||||
return el(
|
||||
@@ -653,10 +827,12 @@ interface RedeemResult {
|
||||
ok: boolean;
|
||||
/** Set when ok: the address the app loads. */
|
||||
instanceUrl?: string;
|
||||
/** Set when ok and a password was chosen: the one-time code that signs the buyer in once the instance answers. */
|
||||
welcome?: string;
|
||||
/** Set when not ok: a stable refusal code. */
|
||||
code?: string;
|
||||
}
|
||||
async function redeemPurchase(slug: string, email: string, purchase: Purchase): Promise<RedeemResult> {
|
||||
async function redeemPurchase(slug: string, email: string, purchase: Purchase, password = ''): Promise<RedeemResult> {
|
||||
try {
|
||||
const res = await CapacitorHttp.post({
|
||||
url: `${CLOUD_URL}/api/iap/redeem`,
|
||||
@@ -670,13 +846,16 @@ async function redeemPurchase(slug: string, email: string, purchase: Purchase):
|
||||
receipt: purchase.receipt,
|
||||
slug,
|
||||
email,
|
||||
// Hashed by the control plane on arrival and set on the new instance; empty for a restore.
|
||||
...(password ? { password } : {}),
|
||||
locale,
|
||||
},
|
||||
});
|
||||
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
|
||||
const body = (data ?? {}) as { instanceUrl?: string; code?: string };
|
||||
const body = (data ?? {}) as { instanceUrl?: string; code?: string; welcome?: string };
|
||||
if ((res.status === 200 || res.status === 201) && typeof body.instanceUrl === 'string') {
|
||||
return { ok: true, instanceUrl: body.instanceUrl };
|
||||
const welcome = typeof body.welcome === 'string' && /^[A-Za-z0-9_-]{43}$/.test(body.welcome) ? body.welcome : undefined;
|
||||
return { ok: true, instanceUrl: body.instanceUrl, ...(welcome ? { welcome } : {}) };
|
||||
}
|
||||
return { ok: false, code: body.code ?? 'redeem-failed' };
|
||||
} catch {
|
||||
@@ -705,6 +884,7 @@ function showClaim(
|
||||
slugInput.setAttribute('autocomplete', 'off');
|
||||
slugInput.setAttribute('maxlength', '40');
|
||||
slugInput.setAttribute('aria-label', t('native.claim.addressLabel'));
|
||||
slugInput.setAttribute('enterkeyhint', 'next');
|
||||
// "name.liketrek.com" is one address, left to right in every language.
|
||||
const slugField = el(
|
||||
'div',
|
||||
@@ -723,12 +903,29 @@ function showClaim(
|
||||
});
|
||||
emailInput.setAttribute('inputmode', 'email');
|
||||
emailInput.setAttribute('autocomplete', 'email');
|
||||
emailInput.setAttribute('enterkeyhint', 'go');
|
||||
emailInput.setAttribute('enterkeyhint', 'next');
|
||||
emailInput.setAttribute('aria-label', t('native.claim.emailLabel'));
|
||||
emailInput.dir = 'ltr';
|
||||
emailInput.classList.add('ltr-value');
|
||||
const emailField = el('div', { className: 'field' }, emailInput);
|
||||
|
||||
// The password for the new TREK, chosen here so the buyer lands signed in (no password by mail).
|
||||
const passwordInput = el('input', {
|
||||
type: 'password',
|
||||
value: '',
|
||||
autocapitalize: 'off',
|
||||
spellcheck: false,
|
||||
placeholder: t('native.claim.passwordLabel'),
|
||||
});
|
||||
passwordInput.setAttribute('autocomplete', 'new-password');
|
||||
passwordInput.setAttribute('enterkeyhint', 'go');
|
||||
passwordInput.setAttribute('maxlength', '128');
|
||||
passwordInput.setAttribute('aria-label', t('native.claim.passwordLabel'));
|
||||
passwordInput.dir = 'ltr';
|
||||
passwordInput.classList.add('ltr-value');
|
||||
const passwordField = el('div', { className: 'field' }, passwordInput);
|
||||
const passwordHint = el('p', { className: 'hint' }, t('native.claim.passwordHint'));
|
||||
|
||||
const submit = el('button', { type: 'submit', className: 'btn' }, t('native.claim.subscribe', { price }));
|
||||
const error = el('p', { className: 'error', role: 'alert' });
|
||||
const form = el(
|
||||
@@ -738,6 +935,8 @@ function showClaim(
|
||||
slugNote,
|
||||
emailField,
|
||||
el('p', { className: 'hint' }, t('native.claim.emailHint')),
|
||||
passwordField,
|
||||
passwordHint,
|
||||
// Our terms govern the instance (terms § 5a): named before the purchase, not only in the confirmation mail.
|
||||
el(
|
||||
'p',
|
||||
@@ -750,6 +949,17 @@ function showClaim(
|
||||
error
|
||||
);
|
||||
|
||||
// "Next" moves on to the next field instead of sending the form half filled;
|
||||
// "Go" on the password sends it.
|
||||
const nextOnEnter = (from: HTMLInputElement, to: HTMLInputElement) =>
|
||||
from.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' || e.isComposing) return;
|
||||
e.preventDefault();
|
||||
to.focus();
|
||||
});
|
||||
nextOnEnter(slugInput, emailInput);
|
||||
nextOnEnter(emailInput, passwordInput);
|
||||
|
||||
let status: SlugStatus | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const noteFor: Partial<Record<SlugStatus, string>> = {
|
||||
@@ -796,11 +1006,23 @@ function showClaim(
|
||||
emailInput.focus();
|
||||
return;
|
||||
}
|
||||
// Before anything is bought: a password TREK would refuse must not cost a purchase first.
|
||||
const password = passwordInput.value;
|
||||
if (passwordProblem(password) || password.length > 128) {
|
||||
shake(passwordField);
|
||||
passwordHint.className = 'hint bad';
|
||||
passwordInput.focus();
|
||||
return;
|
||||
}
|
||||
passwordHint.className = 'hint';
|
||||
|
||||
void tap(ImpactStyle.Medium);
|
||||
// The purchase sheet and the progress want the whole screen, not the keyboard.
|
||||
passwordInput.blur();
|
||||
const lock = (on: boolean) => {
|
||||
slugInput.disabled = on;
|
||||
emailInput.disabled = on;
|
||||
passwordInput.disabled = on;
|
||||
submit.disabled = on;
|
||||
};
|
||||
lock(true);
|
||||
@@ -831,10 +1053,10 @@ function showClaim(
|
||||
bought = outcome.purchase;
|
||||
}
|
||||
|
||||
const redeemed = await redeemPurchase(slug, email, bought);
|
||||
const redeemed = await redeemPurchase(slug, email, bought, password);
|
||||
if (redeemed.ok && redeemed.instanceUrl) {
|
||||
await iap.finish(bought);
|
||||
showProvisioning(redeemed.instanceUrl);
|
||||
showProvisioning(redeemed.instanceUrl, redeemed.welcome);
|
||||
return;
|
||||
}
|
||||
void notify(NotificationType.Error);
|
||||
@@ -843,6 +1065,10 @@ function showClaim(
|
||||
shake(slugField);
|
||||
slugNote.textContent = t('native.claim.taken');
|
||||
slugNote.className = 'hint bad';
|
||||
} else if (redeemed.code === 'password-weak') {
|
||||
// A common password the list on the server knows: the purchase waits, unredeemed, for a better one.
|
||||
shake(passwordField);
|
||||
passwordHint.className = 'hint bad';
|
||||
} else {
|
||||
error.textContent = refundedBeforeOpening(redeemed.code, onPlay)
|
||||
? t('native.claim.closedRefund')
|
||||
@@ -869,7 +1095,7 @@ function showClaim(
|
||||
}
|
||||
|
||||
/** The build runs on the server; the app waits on the new address and loads it the moment it answers. */
|
||||
function showProvisioning(instanceUrl: string): void {
|
||||
function showProvisioning(instanceUrl: string, welcome?: string): void {
|
||||
const host = (() => {
|
||||
try {
|
||||
return new URL(instanceUrl).host;
|
||||
@@ -893,11 +1119,11 @@ function showProvisioning(instanceUrl: string): void {
|
||||
const poll = async (): Promise<void> => {
|
||||
const result = await probeServer(instanceUrl, get);
|
||||
if (result.ok) {
|
||||
await connected(result.url);
|
||||
await connected(result.url, [], welcome);
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
showProvisioningSlow(instanceUrl);
|
||||
showProvisioningSlow(instanceUrl, welcome);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => void poll(), 4000);
|
||||
@@ -905,12 +1131,12 @@ function showProvisioning(instanceUrl: string): void {
|
||||
setTimeout(() => void poll(), 3000);
|
||||
}
|
||||
|
||||
function showProvisioningSlow(instanceUrl: string): void {
|
||||
function showProvisioningSlow(instanceUrl: string, welcome?: string): void {
|
||||
void notify(NotificationType.Warning);
|
||||
const retry = el('button', { type: 'button', className: 'btn' }, t('native.provisioning.retry'));
|
||||
retry.addEventListener('click', () => {
|
||||
void tap();
|
||||
showProvisioning(instanceUrl);
|
||||
showProvisioning(instanceUrl, welcome);
|
||||
});
|
||||
show(
|
||||
el(
|
||||
@@ -941,8 +1167,13 @@ 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> {
|
||||
/**
|
||||
* The tick, then the server takes over. A beat so the success registers. With a
|
||||
* welcome code (a purchase with a chosen password) the app opens the instance's
|
||||
* welcome page once, which signs the buyer in; an app build without that option
|
||||
* opens the sign-in page instead, where the chosen password works.
|
||||
*/
|
||||
async function connected(url: string, signInHosts: string[] = [], welcome?: string): Promise<void> {
|
||||
void notify(NotificationType.Success);
|
||||
const host = (() => {
|
||||
try {
|
||||
@@ -967,11 +1198,17 @@ 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({
|
||||
url,
|
||||
...(signInHosts.length ? { signInHosts } : {}),
|
||||
...(welcome ? { welcome } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function showUnreachable(server: string): void {
|
||||
const retry = el('button', { type: 'button', className: 'btn' }, t('native.offline.retry'));
|
||||
function showUnreachable(server: string, cert?: ClientCertDemand): void {
|
||||
const certPanel = el('div', { className: 'gate', role: 'alert', hidden: true });
|
||||
if (cert) fillCertPanel(certPanel, cert, () => void TrekShell.reload());
|
||||
const retry = el('button', { type: 'button', className: cert ? 'linkbtn' : 'btn' }, t('native.offline.retry'));
|
||||
retry.addEventListener('click', () => {
|
||||
void tap();
|
||||
retry.disabled = true;
|
||||
@@ -989,10 +1226,16 @@ function showUnreachable(server: string): void {
|
||||
...columns(
|
||||
[
|
||||
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
|
||||
el('h1', { className: 'title-sm' }, t('native.offline.title')),
|
||||
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
|
||||
el('h1', { className: 'title-sm' }, cert ? t('native.cert.unreachableTitle') : t('native.offline.title')),
|
||||
el(
|
||||
'p',
|
||||
{ className: 'sub' },
|
||||
cert
|
||||
? t('native.cert.unreachableHint', { server: new URL(server).host })
|
||||
: t('native.offline.hint', { server: new URL(server).host })
|
||||
),
|
||||
],
|
||||
[retry, change, footLinks()]
|
||||
[certPanel, retry, change, footLinks()]
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -1013,8 +1256,71 @@ async function paintStatusBar(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android's back gesture steps back through the first screens as their own
|
||||
* back button does, and only leaves the app from a screen that has none.
|
||||
*/
|
||||
function followBackGesture(): void {
|
||||
try {
|
||||
void App.addListener('backButton', () => {
|
||||
const back = current?.querySelector<HTMLButtonElement>('.topbar .back');
|
||||
if (back) back.click();
|
||||
else void App.minimizeApp();
|
||||
});
|
||||
} catch {
|
||||
/* web / unavailable: no system back gesture */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyboard closes the ways it does in an iOS app: a tap beside the fields,
|
||||
* or a drag of the screen. A web view keeps it open for both, and on the claim
|
||||
* screen the keyboard covered the hints and the terms with no way to put it away.
|
||||
*/
|
||||
function letKeyboardClose(): void {
|
||||
const isField = (target: EventTarget | null) =>
|
||||
target instanceof Element && !!target.closest('input, textarea, select, [contenteditable="true"], .field');
|
||||
const close = () => {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && isField(active)) active.blur();
|
||||
};
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
if (!isField(e.target)) close();
|
||||
}, true);
|
||||
let startY: number | null = null;
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
startY = isField(e.target) ? null : (e.touches[0]?.clientY ?? null);
|
||||
}, { passive: true });
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
const y = e.touches[0]?.clientY;
|
||||
if (startY !== null && y !== undefined && Math.abs(y - startY) > 10) {
|
||||
startY = null;
|
||||
close();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the remembered server failed for want of a client certificate. Asked
|
||||
* only when the app saw that server ask for one in this run, and then checked
|
||||
* once more, so a server that is merely offline is not taken for one that
|
||||
* refused the certificate.
|
||||
*/
|
||||
async function certificateDemand(server: string): Promise<ClientCertDemand | null> {
|
||||
try {
|
||||
const { request } = await TrekShell.clientCertificate();
|
||||
if (!request || request.host !== new URL(server).hostname.toLowerCase()) return null;
|
||||
const result = await probeServer(server, get);
|
||||
return result.ok ? null : (result.cert ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
void paintStatusBar();
|
||||
followBackGesture();
|
||||
letKeyboardClose();
|
||||
let url: string | null;
|
||||
try {
|
||||
({ url } = await TrekShell.getServer());
|
||||
@@ -1029,11 +1335,18 @@ async function start(): Promise<void> {
|
||||
if (!url) {
|
||||
showWelcome();
|
||||
} else {
|
||||
// Only reached when loading the server failed. On iOS the stored copy of
|
||||
// its start page brings the offline-capable UI up anyway.
|
||||
const { opened } = await TrekShell.openOfflineCopy();
|
||||
if (opened) return;
|
||||
showUnreachable(url);
|
||||
// Only reached when loading the server failed. A proxy that wanted a client
|
||||
// certificate says so here, before the offline copy would start a UI that
|
||||
// cannot reach its server.
|
||||
const cert = await certificateDemand(url);
|
||||
if (cert) {
|
||||
showUnreachable(url, cert);
|
||||
} else {
|
||||
// On iOS the stored copy of its start page brings the offline-capable UI up anyway.
|
||||
const { opened } = await TrekShell.openOfflineCopy();
|
||||
if (opened) return;
|
||||
showUnreachable(url);
|
||||
}
|
||||
}
|
||||
await SplashScreen.hide();
|
||||
}
|
||||
|
||||
@@ -164,10 +164,13 @@
|
||||
--veil-mid: rgba(255, 255, 255, 0.74);
|
||||
color-scheme: light;
|
||||
}
|
||||
.shell-logo,
|
||||
.mark {
|
||||
filter: none;
|
||||
}
|
||||
/* The app icon is drawn white; on the light surface it turns black. */
|
||||
.shell-logo {
|
||||
filter: brightness(0);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -189,6 +192,9 @@ body {
|
||||
text-rendering: optimizeLegibility;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* A double tap on a button zoomed the screen on iOS. An app does not zoom:
|
||||
taps stay taps, panning still scrolls. */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -753,6 +759,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;
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.1.1",
|
||||
"version": "5.1.2",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -53,9 +53,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@capacitor/action-sheet": "^8.1.1",
|
||||
"@capacitor/android": "^8.5.0",
|
||||
"@capacitor/app": "^8.1.1",
|
||||
"@capacitor/core": "^8.5.0",
|
||||
"@capacitor/dialog": "^8.0.1",
|
||||
"@capacitor/filesystem": "^8.1.3",
|
||||
"@capacitor/haptics": "^8.0.2",
|
||||
"@capacitor/ios": "^8.5.0",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import touchHover from './postcss/touchHover.js';
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
// touchHover last: it guards the :hover rules Tailwind has just written too.
|
||||
plugins: [tailwindcss(), autoprefixer(), touchHover()],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Hover styles only while a pointer drives the UI.
|
||||
*
|
||||
* A finger has no hover, yet WebKit and Chrome apply :hover to whatever was
|
||||
* tapped and keep it there until the next tap lands somewhere else. In the
|
||||
* iOS and Android app that left buttons and rows tinted after every tap, a
|
||||
* tell of a web page that no native app has. The app marks <html> with
|
||||
* data-touch-input while a finger is in use (src/native/touchInput.ts), and
|
||||
* this plugin makes every :hover rule, Tailwind's and our own, wait for that
|
||||
* mark to be gone. A browser never sets it, so nothing changes there, and an
|
||||
* iPad with a trackpad clears it on the first pointer move.
|
||||
*
|
||||
* The condition sits on the hovered element itself, wrapped in :where(), so a
|
||||
* rule keeps its specificity and still wins or loses where it did before. A
|
||||
* :hover after a pseudo-element (::-webkit-slider-thumb:hover) stays as it is:
|
||||
* nothing may follow it there, and a rule with an invalid selector is dropped
|
||||
* whole.
|
||||
*/
|
||||
|
||||
export const TOUCH_INPUT_ATTR = 'data-touch-input';
|
||||
const GUARD = `:where(:root:not([${TOUCH_INPUT_ATTR}]) *)`;
|
||||
// :hover itself, not the escaped `hover\:` inside a Tailwind class name, and not :hover-something.
|
||||
const HOVER = /(?<!\\):hover(?![\w-])/g;
|
||||
const COMPOUND_START = /[\s>+~(,]/;
|
||||
|
||||
function compoundBefore(selector, index) {
|
||||
let i = index - 1;
|
||||
while (i >= 0 && !COMPOUND_START.test(selector[i])) i--;
|
||||
return selector.slice(i + 1, index);
|
||||
}
|
||||
|
||||
export function guardHover(selector) {
|
||||
if (selector.includes(GUARD)) return selector;
|
||||
return selector.replace(HOVER, (match, offset) =>
|
||||
compoundBefore(selector, offset).includes('::') ? match : `${match}${GUARD}`
|
||||
);
|
||||
}
|
||||
|
||||
export default function touchHover() {
|
||||
return {
|
||||
postcssPlugin: 'trek-touch-hover',
|
||||
Rule(rule) {
|
||||
if (!rule.selector.includes(':hover')) return;
|
||||
const next = rule.selectors.map(guardHover);
|
||||
if (next.some((s, i) => s !== rule.selectors[i])) rule.selectors = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
touchHover.postcss = true;
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 375,
|
||||
"@typescript-eslint/no-misused-promises": 845,
|
||||
"@typescript-eslint/no-misused-promises": 843,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
"@typescript-eslint/no-explicit-any": 144,
|
||||
|
||||
@@ -1028,6 +1028,7 @@
|
||||
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
|
||||
"src/mobile/screens/journey/MJourneyFeed.tsx": "Markup only; the journey's entries as a feed beside the map on a wide window, with the entries, the active one and the tap handed in through props from useJourneyDetail and useJourneyCarouselSync.",
|
||||
"src/mobile/screens/settings/MAppLockCard.tsx": "Phone only: the app lock exists only in the native app, so there is no desktop setting to share with.",
|
||||
"src/mobile/screens/settings/MClientCertCard.tsx": "Phone only: the client certificate is kept by the native app, so there is no desktop setting to share with.",
|
||||
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
||||
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
||||
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"shared.ts": 2,
|
||||
"storage.ts": 16,
|
||||
"system_notice.ts": 32,
|
||||
"todo.ts": 3,
|
||||
"todo.ts": 2,
|
||||
"tours.ts": 12,
|
||||
"trip.ts": 10,
|
||||
"undo.ts": 1,
|
||||
|
||||
@@ -38,6 +38,7 @@ import AppLockGate from './components/Layout/AppLockGate'
|
||||
import NativeImportSheet from './mobile/components/NativeImportSheet'
|
||||
import MPushPrompt from './mobile/components/MPushPrompt'
|
||||
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
|
||||
import NativeQuickActionsSync from './components/Layout/NativeQuickActionsSync'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
import HelpPanel from './components/Help/HelpPanel'
|
||||
// Notice action registrations (side-effect imports):
|
||||
@@ -456,6 +457,7 @@ export default function App() {
|
||||
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
|
||||
{!hideAuthedWidgets && isPhone && <ErrorBoundary boundaryId="widget:push-prompt" fallback={null}><MPushPrompt /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:native-quick-actions" fallback={null}><NativeQuickActionsSync /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
across navigations. react-router runs location updates inside a transition,
|
||||
so a mounted boundary keeps the current page on screen instead of flashing
|
||||
|
||||
@@ -77,10 +77,24 @@ describe('CollectionsWidget', () => {
|
||||
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-008: nothing renders while the fetch is still in flight', async () => {
|
||||
render(<CollectionsWidget onOpen={() => {}} />);
|
||||
it('FE-COMP-COLWIDGET-008: only a placeholder shows while the fetch is still in flight', async () => {
|
||||
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
expect(screen.queryByText('No saved places yet')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('No saved places yet')).toBeInTheDocument());
|
||||
// Nothing remembered yet: the empty hint's shape, hidden from assistive tech, holds the place.
|
||||
expect(container.querySelector('.col-empty.trek-skeleton')).toHaveAttribute('aria-hidden', 'true');
|
||||
await waitFor(() => expect(container.querySelector('.trek-skeleton')).toBeNull());
|
||||
expect(screen.getByText('No saved places yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-009: #3009 holds the badges it showed last time while the lists load, then remembers the new count', async () => {
|
||||
localStorage.setItem('trek_dash_collections_shown', '3');
|
||||
collections([1, 2].map(id => list({ id, name: `List ${id}` })));
|
||||
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(3);
|
||||
expect(await screen.findByText('List 1')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(0);
|
||||
await waitFor(() => expect(localStorage.getItem('trek_dash_collections_shown')).toBe('2'));
|
||||
localStorage.clear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Bookmark, ArrowRight, MapPin } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { entityGradient } from '../../utils/gradients'
|
||||
import { useCollectionLists } from './useCollectionLists'
|
||||
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory'
|
||||
|
||||
/**
|
||||
* Dashboard sidebar widget — a glassy `.tool` card that surfaces the user's
|
||||
@@ -16,6 +17,10 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { lists, loading } = useCollectionLists()
|
||||
const [expected] = useState(readShownCollections)
|
||||
useEffect(() => {
|
||||
if (!loading) rememberShownCollections(Math.min(lists.length, 6))
|
||||
}, [loading, lists.length])
|
||||
|
||||
return (
|
||||
<div className="tool">
|
||||
@@ -25,7 +30,15 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{loading ? null : lists.length === 0 ? (
|
||||
{loading ? (
|
||||
expected ? (
|
||||
<div className="col-badges" aria-hidden>
|
||||
{Array.from({ length: expected }, (_, i) => <div key={i} className="col-badge col-badge-pending trek-skeleton" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-empty trek-skeleton" aria-hidden>{t('collections.widget.empty')}</div>
|
||||
)
|
||||
) : lists.length === 0 ? (
|
||||
<div className="col-empty">{t('collections.widget.empty')}</div>
|
||||
) : (
|
||||
<div className="col-badges">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// FE-COMP-COLMEMORY-001 to -002: how many lists the collections widget showed last time.
|
||||
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory';
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe('collectionsWidgetMemory', () => {
|
||||
it('FE-COMP-COLMEMORY-001: remembers the count, kept within the six badges the widget shows', () => {
|
||||
expect(readShownCollections()).toBeNull();
|
||||
rememberShownCollections(4);
|
||||
expect(readShownCollections()).toBe(4);
|
||||
rememberShownCollections(9);
|
||||
expect(readShownCollections()).toBe(6);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMEMORY-002: ignores a value that is not a count', () => {
|
||||
localStorage.setItem('trek_dash_collections_shown', 'many');
|
||||
expect(readShownCollections()).toBeNull();
|
||||
localStorage.setItem('trek_dash_collections_shown', '42');
|
||||
expect(readShownCollections()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
const KEY = 'trek_dash_collections_shown';
|
||||
|
||||
/**
|
||||
* How many lists the dashboard's collections widget showed last time. While the
|
||||
* lists load, the widget stands in with that many placeholder badges, so it
|
||||
* opens at the height it will have and the widgets under it stay put (#3009).
|
||||
* Null when nothing is remembered yet, or storage is unavailable.
|
||||
*/
|
||||
export function readShownCollections(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw == null) return null;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(n) && n >= 0 && n <= 6 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberShownCollections(count: number): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, String(Math.max(0, Math.min(6, count))));
|
||||
} catch {
|
||||
/* storage blocked: the widget just opens at its empty height next time */
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-FXHOOK-001 to -008: the currency converter both dashboards share.
|
||||
// FE-COMP-FXHOOK-001 to -010: the currency converter both dashboards share.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -13,7 +13,7 @@ const RATES = [
|
||||
const updateSetting = vi.fn<(key: string, value: unknown) => Promise<void>>();
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function seedSettings(over: Record<string, unknown> = {}, isLoaded = false) {
|
||||
function seedSettings(over: Record<string, unknown> = {}, isLoaded = true) {
|
||||
useSettingsStore.setState((s) => ({
|
||||
isLoaded,
|
||||
updateSetting: updateSetting as unknown as typeof s.updateSetting,
|
||||
@@ -133,4 +133,33 @@ describe('useCurrencyConverter', () => {
|
||||
});
|
||||
expect(result.current.rate).toBe(1.3);
|
||||
});
|
||||
|
||||
it('FE-COMP-FXHOOK-009: #3009 waits for the saved pair before it fetches or shows a pair', async () => {
|
||||
seedSettings({}, false);
|
||||
const { result } = renderHook(() => useCurrencyConverter());
|
||||
expect(result.current.pairPending).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
|
||||
expect(result.current.pairPending).toBe(false);
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('base=JPY');
|
||||
});
|
||||
|
||||
it('FE-COMP-FXHOOK-010: #3009 never prices a new pair with the previous base and says it is still loading', async () => {
|
||||
fetchMock = vi.fn((url: string) =>
|
||||
respond(url.endsWith('base=JPY') ? [{ quote: 'EUR', rate: 0.0056 }] : [{ quote: 'USD', rate: 1.1 }])
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { result } = renderHook(() => useCurrencyConverter());
|
||||
await waitFor(() => expect(result.current.rate).toBe(1.1));
|
||||
|
||||
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
|
||||
// EUR's map holds EUR at 1, which used to show as "1 JPY = 1.0000 EUR".
|
||||
expect(result.current.rate).toBeNull();
|
||||
expect(result.current.ratePending).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.rate).toBe(0.0056));
|
||||
expect(result.current.ratePending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,10 +23,18 @@ export function useCurrencyConverter() {
|
||||
updateSetting('dashboard_fx_to', v).catch(() => {});
|
||||
};
|
||||
const [amount, setAmount] = useState('100');
|
||||
const [rates, setRates] = useState<Record<string, number> | null>(null);
|
||||
// The rates carry the base they were fetched for: the pair can change before
|
||||
// the new base's rates arrive, and the old map priced the new pair wrongly
|
||||
// (EUR's self-rate of 1 showed as "1 JPY = 1.0000 EUR", #3009).
|
||||
const [rates, setRates] = useState<{ base: string; map: Record<string, number> } | null>(null);
|
||||
// The base whose rates could not be had, so a failure belongs to its pair only.
|
||||
const [failedBase, setFailedBase] = useState<string | null>(null);
|
||||
const inFlight = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchRates = useCallback(() => {
|
||||
// Not before the user's pair is known: the defaults' rates were fetched and
|
||||
// shown first, then replaced by the saved pair (#3009).
|
||||
if (!isLoaded) return;
|
||||
inFlight.current?.abort();
|
||||
const controller = new AbortController();
|
||||
inFlight.current = controller;
|
||||
@@ -37,18 +45,22 @@ export function useCurrencyConverter() {
|
||||
if (signal.aborted) return;
|
||||
if (!Array.isArray(d)) {
|
||||
setRates(null);
|
||||
setFailedBase(from);
|
||||
return;
|
||||
}
|
||||
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
|
||||
const map: Record<string, number> = { [from]: 1 };
|
||||
for (const r of d) map[r.quote] = r.rate;
|
||||
setRates(map);
|
||||
setFailedBase(null);
|
||||
setRates({ base: from, map });
|
||||
})
|
||||
.catch(() => {
|
||||
// An abort is not a failure: it means nobody is waiting for the answer.
|
||||
if (!signal.aborted) setRates(null);
|
||||
if (signal.aborted) return;
|
||||
setRates(null);
|
||||
setFailedBase(from);
|
||||
});
|
||||
}, [from]);
|
||||
}, [from, isLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRates();
|
||||
@@ -78,13 +90,32 @@ export function useCurrencyConverter() {
|
||||
});
|
||||
}, [isLoaded, updateSetting]);
|
||||
|
||||
const currencies: string[] = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
|
||||
const rate = rates?.[to] ?? null;
|
||||
const current = rates?.base === from ? rates.map : null;
|
||||
const currencies: string[] = current ? Object.keys(current).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
|
||||
const rate = current?.[to] ?? null;
|
||||
// The pair is not known yet, or its rates are still on their way: neither is
|
||||
// "rate unavailable", and neither may show a rate.
|
||||
const pairPending = !isLoaded;
|
||||
const ratePending = isLoaded && !current && failedBase !== from;
|
||||
const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null;
|
||||
const swap = () => {
|
||||
setFrom(to);
|
||||
setTo(from);
|
||||
};
|
||||
|
||||
return { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates };
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
setFrom,
|
||||
setTo,
|
||||
amount,
|
||||
setAmount,
|
||||
currencies,
|
||||
rate,
|
||||
converted,
|
||||
swap,
|
||||
fetchRates,
|
||||
pairPending,
|
||||
ratePending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* NativeQuickActionsSync: keeps the app icon's quick actions (native/quickActions.ts)
|
||||
* in step with the trips on this device and the app's language. Runs at start,
|
||||
* on a change of language or account, and whenever the app comes back to the
|
||||
* foreground. Signed out, the icon offers nothing that would open a trip.
|
||||
* Renders nothing; outside an iOS binary with the feature it does nothing.
|
||||
*/
|
||||
import { App } from '@capacitor/app';
|
||||
import { useEffect } from 'react';
|
||||
import { offlineDb } from '../../db/offlineDb';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { publishQuickActions, quickActionsActive, quickActionsFor } from '../../native/quickActions';
|
||||
import { pickTrip } from '../../native/widgets';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { localDateStr } from '../../vacay/monthGrid';
|
||||
|
||||
export default function NativeQuickActionsSync() {
|
||||
const { t, language } = useTranslation();
|
||||
const { caps } = useNativeCapabilities();
|
||||
const userId = useAuthStore((s) => (s.isAuthenticated ? (s.user?.id ?? null) : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (!quickActionsActive()) return;
|
||||
let cancelled = false;
|
||||
|
||||
const sync = async () => {
|
||||
if (userId === null) {
|
||||
await publishQuickActions([]);
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const trips = await offlineDb.trips.toArray();
|
||||
if (cancelled) return;
|
||||
await publishQuickActions(quickActionsFor(pickTrip(trips, today), today, t));
|
||||
};
|
||||
|
||||
void sync();
|
||||
const listener = App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive) void sync();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void listener.then((handle) => handle.remove());
|
||||
};
|
||||
}, [caps, t, language, userId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -105,6 +105,88 @@ describe('buildGlMap', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('followContainerSize', () => {
|
||||
type Observed = { callback: ResizeObserverCallback; target: Element | null; disconnected: boolean };
|
||||
const observers: Observed[] = [];
|
||||
|
||||
function fakeMap() {
|
||||
let onRemove: (() => void) | undefined;
|
||||
const container = document.createElement('div');
|
||||
return {
|
||||
map: {
|
||||
getContainer: () => container,
|
||||
resize: vi.fn(),
|
||||
once: (_type: 'remove', listener: () => void) => {
|
||||
onRemove = listener;
|
||||
},
|
||||
},
|
||||
container,
|
||||
remove: () => onRemove?.(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0;
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
private readonly entry: Observed;
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.entry = { callback, target: null, disconnected: false };
|
||||
observers.push(this.entry);
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.entry.target = target;
|
||||
}
|
||||
disconnect() {
|
||||
this.entry.disconnected = true;
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const fire = () => observers[0].callback([], {} as ResizeObserver);
|
||||
|
||||
it('FE-MAP-GLSUP-020: resizes the canvas when its box changes size without a window resize (#3023)', () => {
|
||||
const { map, container } = fakeMap();
|
||||
expect(buildGlMap(() => map)).toBe(map);
|
||||
expect(observers[0].target).toBe(container);
|
||||
|
||||
fire();
|
||||
fire();
|
||||
fire();
|
||||
expect(map.resize).not.toHaveBeenCalled();
|
||||
vi.advanceTimersToNextFrame();
|
||||
expect(map.resize).toHaveBeenCalledTimes(1);
|
||||
|
||||
fire();
|
||||
vi.advanceTimersToNextFrame();
|
||||
expect(map.resize).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-021: stops watching once the map is removed, a pending resize included', () => {
|
||||
const { map, remove } = fakeMap();
|
||||
buildGlMap(() => map);
|
||||
|
||||
fire();
|
||||
remove();
|
||||
vi.advanceTimersToNextFrame();
|
||||
expect(observers[0].disconnected).toBe(true);
|
||||
expect(map.resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-022: leaves a map without a container alone', () => {
|
||||
expect(buildGlMap(() => ({ remove: vi.fn() }))).toEqual({ remove: expect.any(Function) });
|
||||
expect(observers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGlRenderer', () => {
|
||||
it('FE-MAP-GLSUP-010: asks the canvas for webgl2 only', () => {
|
||||
const getContext = browserWithWebGL2(false);
|
||||
|
||||
@@ -75,10 +75,52 @@ export function initialMapStyle(provider: GlMapProvider, style: string): string
|
||||
|
||||
export function buildGlMap<T>(construct: () => T): T | null {
|
||||
try {
|
||||
return construct();
|
||||
const map = construct();
|
||||
if (isResizableGlMap(map)) followContainerSize(map);
|
||||
return map;
|
||||
} catch (err) {
|
||||
if (!isGpuInitFailure(err)) throw err;
|
||||
reportWebGLFailure();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ResizableGlMap {
|
||||
getContainer(): HTMLElement;
|
||||
resize(): unknown;
|
||||
once(type: 'remove', listener: () => void): unknown;
|
||||
}
|
||||
|
||||
function isResizableGlMap(map: unknown): map is ResizableGlMap {
|
||||
const candidate = map as Partial<ResizableGlMap> | null;
|
||||
return (
|
||||
typeof candidate?.getContainer === 'function' &&
|
||||
typeof candidate.resize === 'function' &&
|
||||
typeof candidate.once === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a GL map's canvas the size of its box. Mapbox GL measures its container
|
||||
* on window resizes only, so a box that grew without one (the trip's plan pane
|
||||
* closing beside the map in the app on a tablet, a foldable unfolding) kept the
|
||||
* old canvas, with the rest of the box left empty and black (#3023). MapLibre
|
||||
* watches its container itself; one more measure per frame costs it nothing.
|
||||
* Several changes in one frame (the pane's slide) make a single resize.
|
||||
*/
|
||||
function followContainerSize(map: ResizableGlMap): void {
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
let frame = 0;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
map.resize();
|
||||
});
|
||||
});
|
||||
observer.observe(map.getContainer());
|
||||
map.once('remove', () => {
|
||||
observer.disconnect();
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -569,7 +569,9 @@ describe('ArtikelZeile — weight and bag', () => {
|
||||
)
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
|
||||
|
||||
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } })
|
||||
const weightField = container.querySelector('input[placeholder="—"]')!
|
||||
fireEvent.change(weightField, { target: { value: '450' } })
|
||||
fireEvent.blur(weightField)
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ weight_grams: 450 }))
|
||||
})
|
||||
@@ -587,7 +589,9 @@ describe('ArtikelZeile — weight and bag', () => {
|
||||
bagTrackingEnabled: true,
|
||||
})
|
||||
|
||||
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '' } })
|
||||
const weightField = container.querySelector('input[placeholder="—"]')!
|
||||
fireEvent.change(weightField, { target: { value: '' } })
|
||||
fireEvent.blur(weightField)
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
|
||||
})
|
||||
@@ -596,7 +600,9 @@ describe('ArtikelZeile — weight and bag', () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
const { container } = setup({ bagTrackingEnabled: true })
|
||||
|
||||
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } })
|
||||
const weightField = container.querySelector('input[placeholder="—"]')!
|
||||
fireEvent.change(weightField, { target: { value: '450' } })
|
||||
fireEvent.blur(weightField)
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||
})
|
||||
@@ -609,6 +615,7 @@ describe('ArtikelZeile — weight and bag', () => {
|
||||
const weight = container.querySelector<HTMLInputElement>('input[placeholder="—"]')!
|
||||
expect(weight.readOnly).toBe(true)
|
||||
fireEvent.change(weight, { target: { value: '450' } })
|
||||
fireEvent.blur(weight)
|
||||
fireEvent.click(bagButton(container))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: /Carry-on/ })).toBeNull())
|
||||
@@ -929,6 +936,7 @@ describe('ArtikelZeile — overflow menu', () => {
|
||||
|
||||
const weight = fieldOf(container, 'Total weight')
|
||||
fireEvent.change(weight, { target: { value: '900' } })
|
||||
fireEvent.blur(weight)
|
||||
await waitFor(() => expect(bodies[0]).toMatchObject({ weight_grams: 900 }))
|
||||
|
||||
// The first entry mirrors the current bag and clears it when tapped.
|
||||
@@ -945,6 +953,7 @@ describe('ArtikelZeile — overflow menu', () => {
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '900' } })
|
||||
fireEvent.blur(fieldOf(container, 'Total weight'))
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||
|
||||
@@ -995,6 +1004,7 @@ describe('ArtikelZeile — overflow menu', () => {
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '' } })
|
||||
fireEvent.blur(fieldOf(container, 'Total weight'))
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ import PackingShareControl from './PackingShareControl'
|
||||
import { PopoverItem } from './PackingPopover'
|
||||
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { WeightInput } from './PackingListPanelWeightInput'
|
||||
import { bringersOf, packingBringerNames } from './packingBringers'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { BringerAvatar, BringersCard, MoreBringers } from './PackingBringersCard'
|
||||
@@ -109,6 +109,10 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
catch { toast.error(t('common.error')) }
|
||||
}
|
||||
|
||||
const saveWeight = async (grams: number | null) => {
|
||||
try { await updatePackingItem(tripId, item.id, { weight_grams: grams }) } catch { toast.error(t('packing.toast.saveError')) }
|
||||
}
|
||||
|
||||
const canDrag = canEdit && !isPlaceholder && !!drag
|
||||
const selectedBag = bags.find(b => b.id === item.bag_id)
|
||||
const quantity = item.quantity || 1
|
||||
@@ -251,17 +255,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
{bagTrackingEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', ...badge(active || item.weight_grams != null) }}>
|
||||
<NumericInput
|
||||
value={item.weight_grams ?? ''}
|
||||
readOnly={!canEdit}
|
||||
onValueChange={async raw => {
|
||||
if (!canEdit) return
|
||||
const v = raw === '' ? null : Number.parseInt(raw)
|
||||
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) }
|
||||
}}
|
||||
placeholder="—"
|
||||
style={{ width: 36, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'end', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }}
|
||||
/>
|
||||
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={36} onSave={saveWeight} />
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -381,16 +375,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '6px 8px' }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0 }}>{t('packing.totalWeight')}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent' }}>
|
||||
<NumericInput
|
||||
value={item.weight_grams ?? ''}
|
||||
readOnly={!canEdit}
|
||||
onValueChange={async raw => {
|
||||
const v = raw === '' ? null : Number.parseInt(raw)
|
||||
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) }
|
||||
}}
|
||||
placeholder="—"
|
||||
style={{ width: 42, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'end', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }}
|
||||
/>
|
||||
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={42} onSave={saveWeight} />
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// FE-COMP-PACKING-095 to FE-COMP-PACKING-098
|
||||
import { useState } from 'react';
|
||||
import { vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { WeightInput } from './PackingListPanelWeightInput';
|
||||
|
||||
/** A row whose stored weight changes only once a save answers, as the store does. */
|
||||
function SlowRow({ onSave }: { onSave: (grams: number | null) => void }) {
|
||||
const [stored, setStored] = useState<number | null>(null);
|
||||
return (
|
||||
<WeightInput
|
||||
value={stored}
|
||||
width={36}
|
||||
onSave={(grams) => {
|
||||
onSave(grams);
|
||||
setTimeout(() => setStored(grams), 300);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('WeightInput', () => {
|
||||
it('FE-COMP-PACKING-095: #3005 keeps every typed digit and saves the whole weight once', async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SlowRow onSave={onSave} />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
|
||||
fireEvent.focus(input);
|
||||
for (const typed of ['2', '25', '250']) {
|
||||
fireEvent.change(input, { target: { value: typed } });
|
||||
expect(input.value).toBe(typed);
|
||||
}
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onSave).toHaveBeenCalledWith(250);
|
||||
await waitFor(() => expect(input.value).toBe('250'));
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-096: saves on Enter and stores an empty field as no weight', () => {
|
||||
const onSave = vi.fn();
|
||||
render(<WeightInput value={300} width={36} onSave={onSave} />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
|
||||
input.focus();
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-097: leaves an unchanged weight alone and follows a change from elsewhere', () => {
|
||||
const onSave = vi.fn();
|
||||
const { rerender } = render(<WeightInput value={300} width={36} onSave={onSave} />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<WeightInput value={450} width={36} onSave={onSave} />);
|
||||
expect(input.value).toBe('450');
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-098: a read-only field never saves', () => {
|
||||
const onSave = vi.fn();
|
||||
render(<WeightInput value={300} width={36} readOnly onSave={onSave} />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: '9' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import { NumericInput } from '../shared/NumericInput';
|
||||
|
||||
interface Props {
|
||||
value: number | null | undefined;
|
||||
onSave: (grams: number | null) => void | Promise<void>;
|
||||
readOnly?: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The weight of a packing item, in grams. It keeps what is typed in a draft of
|
||||
* its own and saves once, on blur or Enter, the way the quantity does: saving
|
||||
* every keystroke let a slow reply put the stored value back under the cursor,
|
||||
* so the next digit went onto the old number and a fragment was saved.
|
||||
*/
|
||||
export function WeightInput({ value, onSave, readOnly, width }: Props) {
|
||||
const stored = value == null ? '' : String(value);
|
||||
// What is being typed, while the field has it; null shows the stored weight,
|
||||
// so a change from elsewhere (another member) shows whenever nobody types.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
// A saved weight until the store has it, so the field does not fall back to
|
||||
// the old number for the length of the request.
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const shown = draft ?? pending ?? stored;
|
||||
|
||||
const commit = () => {
|
||||
if (draft === null) return;
|
||||
const typed = draft;
|
||||
setDraft(null);
|
||||
if (readOnly) return;
|
||||
const grams = typed.trim() === '' ? null : Math.max(0, Number.parseInt(typed, 10) || 0);
|
||||
const next = grams == null ? '' : String(grams);
|
||||
if (next === stored) return;
|
||||
setPending(next);
|
||||
void Promise.resolve(onSave(grams)).finally(() => setPending(null));
|
||||
};
|
||||
|
||||
const style: CSSProperties = {
|
||||
width,
|
||||
border: 'none',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
textAlign: 'end',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<NumericInput
|
||||
value={shown}
|
||||
readOnly={readOnly}
|
||||
onFocus={() => setDraft(shown)}
|
||||
onValueChange={setDraft}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
placeholder="—"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -160,7 +160,7 @@ describe('BookingDetailHost', () => {
|
||||
await waitFor(() => expect(toasts).toContainEqual({ message: 'Failed to update', type: 'error' }));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGHOST-011: On map is offered only for a booking the map can show, and closes first', async () => {
|
||||
it('FE-PLANNER-BOOKINGHOST-011: Map is offered only for a booking the map can show, and closes first', async () => {
|
||||
const onShowOnMap = vi.fn(() => { calls.push('map'); });
|
||||
const routed = buildReservation({
|
||||
title: 'Shinkansen', type: 'train',
|
||||
@@ -171,7 +171,7 @@ describe('BookingDetailHost', () => {
|
||||
});
|
||||
const { unmount } = renderHost(routed, { onShowOnMap, isOnMap: () => true });
|
||||
|
||||
const onMap = screen.getByRole('button', { name: 'On map' });
|
||||
const onMap = screen.getByRole('button', { name: 'Map' });
|
||||
expect(onMap).toHaveAttribute('aria-pressed', 'true');
|
||||
await userEvent.click(onMap);
|
||||
expect(onShowOnMap).toHaveBeenCalledWith(routed);
|
||||
@@ -179,7 +179,7 @@ describe('BookingDetailHost', () => {
|
||||
unmount();
|
||||
|
||||
renderHost(buildReservation({ title: 'Nowhere', place_id: null }), { onShowOnMap });
|
||||
expect(screen.queryByRole('button', { name: 'On map' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Map' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGHOST-012: Change route is offered for a transit journey only', async () => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import Modal from './Modal';
|
||||
import { askNatively, canAskNatively } from '../../native/nativeDialogs';
|
||||
|
||||
vi.mock('../../native/nativeDialogs', () => ({ canAskNatively: vi.fn(() => false), askNatively: vi.fn() }));
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
const onClose = vi.fn();
|
||||
@@ -181,4 +185,51 @@ describe('ConfirmDialog', () => {
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('in the app, the system asks', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(canAskNatively).mockReturnValue(false);
|
||||
vi.mocked(askNatively).mockReset();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-NATIVE-001: asks once through the system, with the action in red, and confirms on yes', async () => {
|
||||
vi.mocked(canAskNatively).mockReturnValue(true);
|
||||
vi.mocked(askNatively).mockResolvedValue(true);
|
||||
render(<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} title="Delete place" message="Gone for good." />);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
expect(askNatively).toHaveBeenCalledTimes(1);
|
||||
expect(askNatively).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Delete place', message: 'Gone for good.', danger: true }),
|
||||
);
|
||||
expect(screen.queryByText('Gone for good.')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-NATIVE-002: a no only closes', async () => {
|
||||
vi.mocked(canAskNatively).mockReturnValue(true);
|
||||
vi.mocked(askNatively).mockResolvedValue(false);
|
||||
render(<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} message="Sure?" />);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-NATIVE-003: falls back to the HTML card when the system cannot ask', async () => {
|
||||
vi.mocked(canAskNatively).mockReturnValue(true);
|
||||
vi.mocked(askNatively).mockResolvedValue(null);
|
||||
render(<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} message="Sure?" />);
|
||||
expect(await screen.findByText('Sure?')).toBeTruthy();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-NATIVE-004: keeps a question with a list under it in HTML', () => {
|
||||
vi.mocked(canAskNatively).mockReturnValue(true);
|
||||
render(
|
||||
<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} message="Sure?">
|
||||
<ul><li>three places</li></ul>
|
||||
</ConfirmDialog>,
|
||||
);
|
||||
expect(screen.getByText('three places')).toBeTruthy();
|
||||
expect(askNatively).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { DialogTile, NEUTRAL_TINT, fs } from './DialogShell'
|
||||
import { useLastOpen, usePresence } from './usePresence'
|
||||
import { useNativeQuestion } from '../../native/useNativeQuestion'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -30,10 +31,25 @@ function runConfirm(onConfirm: () => void): void {
|
||||
}
|
||||
|
||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const { mounted, closing } = usePresence(props.isOpen)
|
||||
const { t } = useTranslation()
|
||||
// In the app the system asks (native/nativeDialogs.ts); a list under the question needs the HTML card.
|
||||
const question = props.children
|
||||
? null
|
||||
: {
|
||||
title: props.title || t('common.confirm'),
|
||||
message: props.message,
|
||||
confirmLabel: props.confirmLabel || t('common.delete'),
|
||||
cancelLabel: props.cancelLabel || t('common.cancel'),
|
||||
danger: props.danger ?? true,
|
||||
}
|
||||
const html = useNativeQuestion(props.isOpen, question, (yes) => {
|
||||
if (yes) runConfirm(props.onConfirm)
|
||||
props.onClose()
|
||||
})
|
||||
const { mounted, closing } = usePresence(props.isOpen && html)
|
||||
// The question stays readable while it fades, even when its owner clears the subject on close.
|
||||
const shown = useLastOpen(props, props.isOpen)
|
||||
return mounted ? <ConfirmDialogFrame {...shown} isOpen={props.isOpen} closing={closing} /> : null
|
||||
return mounted ? <ConfirmDialogFrame {...shown} isOpen={props.isOpen && html} closing={closing} /> : null
|
||||
}
|
||||
|
||||
function ConfirmDialogFrame({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useDateFormat } from '../../hooks/useDateFormat';
|
||||
import { useRemeasureSignal } from '../../hooks/useAnchoredPosition';
|
||||
import { useWeekStartDay } from '../../hooks/useWeekStartDay';
|
||||
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek';
|
||||
import { selectionHaptic } from '../../native/haptics';
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
@@ -178,6 +179,7 @@ export function CustomDatePicker({
|
||||
const d = String(day).padStart(2, '0');
|
||||
const iso = `${y}-${m}-${d}`;
|
||||
if (isOutOfRange(iso)) return;
|
||||
if (iso !== value) selectionHaptic();
|
||||
onChange(iso);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
import { useAnchoredPosition, scrollAnchorIntoView } from '../../hooks/useAnchoredPosition'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { selectionHaptic } from '../../native/haptics'
|
||||
|
||||
interface SelectOption {
|
||||
// Callers use both string keys and numeric ids (e.g. day/place ids) as values, and
|
||||
@@ -261,7 +262,7 @@ export default function CustomSelect({
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
enterKeyHint="search" placeholder={t('common.search')}
|
||||
aria-label={t('common.search')}
|
||||
style={{
|
||||
width: '100%', border: '1px solid var(--border-secondary)', borderRadius: 6,
|
||||
@@ -305,7 +306,7 @@ export default function CustomSelect({
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => { onChange(option.value); closeMenu() }}
|
||||
onClick={() => { if (!isSelected) selectionHaptic(); onChange(option.value); closeMenu() }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 10px', borderRadius: 6,
|
||||
|
||||
@@ -44,11 +44,11 @@ describe('Skeleton', () => {
|
||||
})
|
||||
|
||||
describe('composed skeletons', () => {
|
||||
it('FE-W4SKEL-006: SpotlightSkeleton renders the hero backdrop plus two bars', () => {
|
||||
it('FE-W4SKEL-006: SpotlightSkeleton takes the box of the hero and renders the backdrop plus two bars', () => {
|
||||
const { container } = render(<SpotlightSkeleton />)
|
||||
|
||||
expect(container.querySelectorAll('.trek-skeleton')).toHaveLength(3)
|
||||
expect((container.firstElementChild as HTMLElement).style.minHeight).toBe('340px')
|
||||
expect(container.firstElementChild).toHaveClass('hero-trip', 'hero-trip-pending')
|
||||
})
|
||||
|
||||
it('FE-W4SKEL-007: TripCardSkeleton renders a cover plus two text bars', () => {
|
||||
|
||||
@@ -26,13 +26,13 @@ export function Skeleton({
|
||||
|
||||
// Trip card skeleton matching SpotlightCard layout
|
||||
export function SpotlightSkeleton(): React.ReactElement {
|
||||
// The dashboard hero's own box (.hero-trip: 520px on desktop, 340px on a
|
||||
// phone, and its margin), so the hero takes the place it held while loading
|
||||
// and nothing under it moves when it arrives (#3009).
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-3xl overflow-hidden mb-8 bg-surface-tertiary"
|
||||
style={{ minHeight: 340 }}
|
||||
>
|
||||
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
|
||||
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
|
||||
<div className="hero-trip hero-trip-pending" aria-hidden>
|
||||
<div className="trek-skeleton absolute inset-0" />
|
||||
<div className="relative flex h-full flex-col justify-end p-6">
|
||||
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
|
||||
<Skeleton width={220} height={16} radius={4} />
|
||||
</div>
|
||||
|
||||
@@ -769,7 +769,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
--fs-scale-caption: 1;
|
||||
|
||||
--success: #16a34a; --success-soft: #dcfce7;
|
||||
--danger: #dc2626; --danger-soft: #fef2f2;
|
||||
--danger: #dc2626; --danger-soft: #fef2f2; --danger-text: #ffffff;
|
||||
--warning: #d97706; --warning-soft: #fffbeb;
|
||||
--info: #2563eb; --info-soft: #eff6ff;
|
||||
|
||||
@@ -866,7 +866,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
--day-tint-l-max: 0.88;
|
||||
|
||||
--success: #22c55e; --success-soft: rgba(34, 197, 94, 0.15);
|
||||
--danger: #ef4444; --danger-soft: rgba(239, 68, 68, 0.15);
|
||||
--danger: #ef4444; --danger-soft: rgba(239, 68, 68, 0.15); --danger-text: #ffffff;
|
||||
--warning: #f59e0b; --warning-soft: rgba(245, 158, 11, 0.15);
|
||||
--info: #3b82f6; --info-soft: rgba(59, 130, 246, 0.15);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import '@fontsource/geist-sans/600.css'
|
||||
// several .leaflet-* styles.
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './index.css'
|
||||
import './native/nativeApp.css'
|
||||
import './styles/rtl.css'
|
||||
import './styles/ferratas.css'
|
||||
import './styles/paddle.css'
|
||||
@@ -42,6 +43,7 @@ import { maybeInstallTouchDragPolyfill } from './utils/touchDragPolyfill'
|
||||
import { startConnectivityProbe } from './sync/connectivity'
|
||||
import { startNativeApp } from './native/nativeApp'
|
||||
import { requestPersistentStorage } from './sync/persistentStorage'
|
||||
import { isIosDevice } from './utils/iosDevice'
|
||||
import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary'
|
||||
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers'
|
||||
import { schedulePrecacheRepair } from './utils/serviceWorkerShell'
|
||||
@@ -52,6 +54,9 @@ startConnectivityProbe()
|
||||
startNativeApp()
|
||||
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
|
||||
void requestPersistentStorage()
|
||||
// A tile cache past the iOS budget slows every cold start of the home screen app (#3008).
|
||||
// Loaded on iOS only, so the prefetcher stays out of everyone's first chunk.
|
||||
if (isIosDevice()) void import('./sync/tilePrefetcher').then((m) => m.scheduleTileCacheTrim())
|
||||
// Event handlers and async code never reach a boundary; this is where they land.
|
||||
installGlobalErrorHandlers()
|
||||
// A page the precache keeps under a stylesheet's or a script's name would be
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { selectionHaptic } from '../../native/haptics'
|
||||
|
||||
interface MChipProps {
|
||||
active?: boolean
|
||||
@@ -22,10 +23,15 @@ interface MChipProps {
|
||||
|
||||
/** Small pill chip: --m-act when active, neutral --m-ic surface otherwise. */
|
||||
export default function MChip({ active = false, onClick, size = 'sm', pressable = false, className = '', children }: MChipProps) {
|
||||
// A chip that is one choice of a picker clicks as it changes, like a native segmented control.
|
||||
const press = onClick && (() => {
|
||||
if (pressable) selectionHaptic()
|
||||
onClick()
|
||||
})
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onClick={press}
|
||||
aria-pressed={pressable ? active : undefined}
|
||||
className={`inline-flex flex-none items-center gap-[6px] rounded-full font-semibold ${
|
||||
size === 'tap' ? 'px-[13px] py-[10px] text-[0.8125rem]' : 'px-3 py-[7px] text-[0.75rem]'
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import MContextMenu from './MContextMenu';
|
||||
|
||||
describe('MContextMenu', () => {
|
||||
it('FE-MOB-CTXMENU-001: names what it is about, lists the actions, and closes before running one', () => {
|
||||
const onClose = vi.fn();
|
||||
const onDelete = vi.fn(() => expect(onClose).toHaveBeenCalled());
|
||||
render(
|
||||
<MContextMenu
|
||||
open
|
||||
title="Lisbon"
|
||||
onClose={onClose}
|
||||
actions={[
|
||||
{ key: 'edit', label: 'Edit', icon: <span />, onClick: vi.fn() },
|
||||
{ key: 'delete', label: 'Delete', icon: <span />, onClick: onDelete },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Lisbon')).toBeTruthy();
|
||||
const del = screen.getByText('Delete').closest('button') as HTMLElement;
|
||||
expect(del.className).toContain('--m-st-danger');
|
||||
fireEvent.click(del);
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import MSheet from './MSheet';
|
||||
|
||||
/**
|
||||
* The menu a long press on a card opens (useLongPress): what it is about on
|
||||
* top, then its actions, the destructive one in red. The same actions the
|
||||
* card's own buttons offer, so this is a second way, never the only one.
|
||||
*/
|
||||
|
||||
interface ContextAction {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface MContextMenuProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
actions: ContextAction[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function MContextMenu({ open, title, actions, onClose }: MContextMenuProps) {
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" wideWidth="compact" ariaLabel={title}>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-[10px]">
|
||||
<div dir="auto" className="truncate px-[10px] pb-2 pt-1 text-caption font-semibold text-m-muted">
|
||||
{title}
|
||||
</div>
|
||||
{actions.map((action) => {
|
||||
const danger = action.key === 'delete';
|
||||
return (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
action.onClick();
|
||||
}}
|
||||
className={`flex w-full items-center gap-[11px] rounded-xl px-[10px] py-[11px] text-start text-body font-semibold active:bg-[color:var(--m-ic)] ${
|
||||
danger ? 'text-[color:var(--m-st-danger)]' : 'text-m-ink'
|
||||
}`}
|
||||
>
|
||||
<span className={`flex flex-none ${danger ? '' : 'text-m-muted'}`}>{action.icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{action.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { pagesFor } from './dayPagerPages';
|
||||
|
||||
vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() }));
|
||||
|
||||
const DAYS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }] as unknown as Day[];
|
||||
const DAYS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }] as unknown as Day[];
|
||||
|
||||
function setup(selectedDayId: number | null, over: Partial<Parameters<typeof MDayPager>[0]> = {}) {
|
||||
const onSelectDay = vi.fn();
|
||||
@@ -39,17 +39,19 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('pagesFor', () => {
|
||||
it('windows the selection with its neighbours', () => {
|
||||
expect(pagesFor(DAYS, 2).map((p) => [p.dayId, p.active])).toEqual([
|
||||
it('windows the selection with two days on either side, room for a second swipe', () => {
|
||||
expect(pagesFor(DAYS, 3).map((p) => [p.dayId, p.active])).toEqual([
|
||||
[1, false],
|
||||
[2, true],
|
||||
[3, false],
|
||||
[2, false],
|
||||
[3, true],
|
||||
[4, false],
|
||||
[5, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('has no page beyond the first and last day', () => {
|
||||
expect(pagesFor(DAYS, 1).map((p) => p.dayId)).toEqual([1, 2]);
|
||||
expect(pagesFor(DAYS, 4).map((p) => p.dayId)).toEqual([3, 4]);
|
||||
expect(pagesFor(DAYS, 1).map((p) => p.dayId)).toEqual([1, 2, 3]);
|
||||
expect(pagesFor(DAYS, 6).map((p) => p.dayId)).toEqual([4, 5, 6]);
|
||||
});
|
||||
|
||||
it('keeps a page for a selection that points at no day, with the first day as the way back', () => {
|
||||
@@ -67,20 +69,41 @@ describe('MDayPager', () => {
|
||||
|
||||
expect(screen.getByText('page 2 live')).toBeInTheDocument();
|
||||
const pages = Array.from(strip.children) as HTMLElement[];
|
||||
expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true]);
|
||||
expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true, true]);
|
||||
expect(pages[0]).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(pages[1]).not.toHaveAttribute('aria-hidden');
|
||||
});
|
||||
|
||||
it('selects the neighbour the strip comes to rest on, with a detent and an announcement', () => {
|
||||
it('selects the day the strip comes to rest on, with an announcement', () => {
|
||||
const { strip, onSelectDay } = setup(2);
|
||||
|
||||
place(strip, 3);
|
||||
fireEvent(strip, new Event('scrollend'));
|
||||
|
||||
expect(onSelectDay).toHaveBeenCalledWith(3);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('day 3 of 6');
|
||||
});
|
||||
|
||||
it('ticks once as the swipe crosses to another day, while the strip still moves, not again as it lands', () => {
|
||||
const { strip } = setup(2);
|
||||
|
||||
place(strip, 3, true);
|
||||
fireEvent.scroll(strip);
|
||||
fireEvent.scroll(strip);
|
||||
expect(tickHaptic).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('day 3 of 4');
|
||||
|
||||
place(strip, 3);
|
||||
fireEvent(strip, new Event('scrollend'));
|
||||
expect(tickHaptic).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a second swipe made during the snap carries on to the day after', () => {
|
||||
const { strip, onSelectDay } = setup(2);
|
||||
|
||||
place(strip, 4);
|
||||
fireEvent(strip, new Event('scrollend'));
|
||||
|
||||
expect(onSelectDay).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('previews the day a swipe heads for while the strip still moves, and takes it back', () => {
|
||||
|
||||
@@ -4,14 +4,18 @@ import { tickHaptic } from '../../native/haptics';
|
||||
import { pagesFor, type PagerItem } from './dayPagerPages';
|
||||
|
||||
/**
|
||||
* Pages through the trip's days the way a native pager does: the previous,
|
||||
* current and next day sit side by side in a horizontal scroll-snap strip, so
|
||||
* the finger drags real paper, the neighbour slides in with it, and the
|
||||
* browser's compositor owns the gesture. Momentum, rubber-banding at the first
|
||||
* Pages through the trip's days the way a native pager does: the current day
|
||||
* and two on either side sit side by side in a horizontal scroll-snap strip
|
||||
* (dayPagerPages.ts), so the finger drags real paper, the neighbour slides in
|
||||
* with it, and a second swipe made while the first still snaps carries on to
|
||||
* the day after. The browser's compositor owns the gesture. Momentum, rubber-banding at the first
|
||||
* and last day, axis locking against the vertical scroll and the swallowed tap
|
||||
* after a swipe are all the platform's own, in the PWA and the app alike.
|
||||
*
|
||||
* The day only changes once the strip has come to rest on a neighbour. Then
|
||||
* The phone ticks as soon as another page becomes the nearer one, the moment
|
||||
* the swipe is decided, not once the snap has settled, which on iOS comes
|
||||
* a good while later. The day only changes once the strip has come to rest on
|
||||
* another page. Then
|
||||
* the selection moves, the window of three shifts by one and the strip is put
|
||||
* back on the centre page before the next paint, so nothing visibly jumps.
|
||||
* Keys follow the day ids, so the page that slid in keeps its state (scroll
|
||||
@@ -110,10 +114,9 @@ export default function MDayPager({
|
||||
let previewed: number | null = null;
|
||||
|
||||
// The nearer page while the strip moves: a page past the middle is where the
|
||||
// swipe will land unless the finger turns back.
|
||||
// swipe will land unless the finger turns back. Ticks as it changes to
|
||||
// another day, so the finger feels the detent while it is still moving.
|
||||
const preview = () => {
|
||||
const notify = latest.current.onPreview;
|
||||
if (!notify) return;
|
||||
const left = strip.getBoundingClientRect().left;
|
||||
let nearest: HTMLElement | null = null;
|
||||
let distance = Infinity;
|
||||
@@ -130,7 +133,8 @@ export default function MDayPager({
|
||||
const next = dayId != null && Number.isFinite(dayId) ? dayId : null;
|
||||
if (next === previewed) return;
|
||||
previewed = next;
|
||||
notify(next);
|
||||
if (next !== null) tickHaptic();
|
||||
latest.current.onPreview?.(next);
|
||||
};
|
||||
|
||||
const settle = () => {
|
||||
@@ -148,7 +152,6 @@ export default function MDayPager({
|
||||
requested.current = dayId;
|
||||
previewed = null;
|
||||
const s = latest.current;
|
||||
tickHaptic();
|
||||
s.onSelectDay(dayId);
|
||||
const index = s.days.findIndex((d) => d.id === dayId);
|
||||
if (index >= 0) setAnnouncement(s.describeDay(index, s.days.length));
|
||||
|
||||
@@ -7,6 +7,7 @@ import { keepTabInside, useModalFocus } from './modalFocus'
|
||||
import { useSheetDrag } from './useSheetDrag'
|
||||
import { useOverlay } from '../overlayStack'
|
||||
import { tickHaptic } from '../../native/haptics'
|
||||
import { useNativeQuestion } from '../../native/useNativeQuestion'
|
||||
|
||||
export type MSheetVariant = 'card' | 'bottom' | 'drawer'
|
||||
export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque'
|
||||
@@ -185,6 +186,18 @@ export default function MSheet({
|
||||
}: MSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const { asking, markStart, requestClose, wouldAsk, keepEditing, discard, reset } = useDiscardGuard(discardGuard, onClose)
|
||||
// In the app the system asks whether to throw the changes away; the card below is the browser's.
|
||||
const askInSheet = useNativeQuestion(
|
||||
asking,
|
||||
{
|
||||
title: t('common.unsavedTitle'),
|
||||
message: t('common.unsavedMessage'),
|
||||
confirmLabel: t('common.discard'),
|
||||
cancelLabel: t('common.keepEditing'),
|
||||
danger: true,
|
||||
},
|
||||
(yes) => (yes ? discard() : keepEditing()),
|
||||
)
|
||||
const [rendered, setRendered] = useState(open)
|
||||
// Once the enter animation has played it is removed again: its forwards
|
||||
// fill would otherwise override the inline drag transform for good.
|
||||
@@ -305,7 +318,7 @@ export default function MSheet({
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{asking && (
|
||||
{asking && askInSheet && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-[color:var(--m-dim)] p-5 backdrop-blur-[2px]">
|
||||
<div role="alertdialog" aria-label={t('common.unsavedTitle')} className="w-full max-w-[320px] rounded-[22px] border border-[color:var(--m-shbr)] bg-[color:var(--m-sheetop)] p-5">
|
||||
<div className="text-subtitle font-semibold text-m-ink">{t('common.unsavedTitle')}</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import MSkeleton from './MSkeleton';
|
||||
|
||||
describe('MSkeleton', () => {
|
||||
it('FE-MOB-SKELETON-001: is announced once as loading, its shapes hidden from a screen reader', () => {
|
||||
render(<MSkeleton variant="rows" count={3} />);
|
||||
const status = screen.getByRole('status');
|
||||
expect(status.getAttribute('aria-label')).toBeTruthy();
|
||||
const shapes = status.firstElementChild as HTMLElement;
|
||||
expect(shapes.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(shapes.children).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-MOB-SKELETON-002: rows inside a card drop their own frame', () => {
|
||||
render(<MSkeleton variant="rows" count={2} framed={false} />);
|
||||
const first = screen.getByRole('status').firstElementChild?.firstElementChild as HTMLElement;
|
||||
expect(first.className).not.toContain('rounded-[14px]');
|
||||
expect(first.className).toContain('border-b');
|
||||
});
|
||||
|
||||
it('FE-MOB-SKELETON-003: does not pulse with reduced motion', () => {
|
||||
render(<MSkeleton variant="cards" count={1} />);
|
||||
expect(screen.getByRole('status').className).toContain('motion-reduce:animate-none');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
/**
|
||||
* Grey stand-ins in the shape of what is loading, where a native app shows the
|
||||
* list before its content rather than a spinner in an empty screen: rows for a
|
||||
* list, cards for a feed, a cover and lines for a detail page, a plain surface
|
||||
* for a map. They pulse softly, and not at all with reduced motion. Announced
|
||||
* once as "Loading" for a screen reader; the shapes themselves are hidden from it.
|
||||
*/
|
||||
|
||||
type MSkeletonVariant = 'rows' | 'cards' | 'detail' | 'map';
|
||||
|
||||
interface MSkeletonProps {
|
||||
variant: MSkeletonVariant;
|
||||
/** How many rows or cards (rows, cards). */
|
||||
count?: number;
|
||||
/** Rows as cards of their own (default), or as lines inside a card that already frames them. */
|
||||
framed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BLOCK = 'bg-[color:var(--m-ic)]';
|
||||
// Lines of different lengths read as text; equal ones read as a table.
|
||||
const WIDTHS = ['w-2/3', 'w-1/2', 'w-3/4', 'w-3/5'];
|
||||
|
||||
function Row({ i, framed }: { i: number; framed: boolean }) {
|
||||
const frame = framed
|
||||
? 'mb-2 rounded-[14px] border border-[color:var(--m-rowbr)] px-3 py-[10px]'
|
||||
: 'border-b border-[color:var(--m-rowbr)] py-3 last:border-b-0';
|
||||
return (
|
||||
<div className={`flex items-center gap-[11px] ${frame}`}>
|
||||
<span className={`h-9 w-9 flex-none rounded-[10px] ${BLOCK}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block h-3 rounded-full ${BLOCK} ${WIDTHS[i % WIDTHS.length]}`} />
|
||||
<span className={`mt-2 block h-2.5 w-1/3 rounded-full ${BLOCK}`} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ i }: { i: number }) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className={`h-[180px] w-full rounded-[22px] ${BLOCK}`} />
|
||||
<span className={`mt-3 block h-3.5 rounded-full ${BLOCK} ${WIDTHS[i % WIDTHS.length]}`} />
|
||||
<span className={`mt-2 block h-2.5 w-1/3 rounded-full ${BLOCK}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail() {
|
||||
return (
|
||||
<div>
|
||||
<div className={`h-[300px] w-full ${BLOCK}`} />
|
||||
<div className="px-4 pt-5">
|
||||
<span className={`block h-5 w-2/3 rounded-full ${BLOCK}`} />
|
||||
<span className={`mt-3 block h-3 w-1/3 rounded-full ${BLOCK}`} />
|
||||
{WIDTHS.map((w) => (
|
||||
<span key={w} className={`mt-4 block h-3 rounded-full ${BLOCK} ${w}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MSkeleton({ variant, count = 4, framed = true, className = '' }: MSkeletonProps) {
|
||||
const { t } = useTranslation();
|
||||
const items = Array.from({ length: count }, (_, i) => i);
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-skeleton=""
|
||||
aria-label={t('common.loading')}
|
||||
className={`animate-pulse motion-reduce:animate-none ${className}`}
|
||||
>
|
||||
<div aria-hidden="true" className={variant === 'map' ? 'h-full' : undefined}>
|
||||
{variant === 'rows' && items.map((i) => <Row key={i} i={i} framed={framed} />)}
|
||||
{variant === 'cards' && items.map((i) => <Card key={i} i={i} />)}
|
||||
{variant === 'detail' && <Detail />}
|
||||
{variant === 'map' && <div className={`h-full w-full ${BLOCK}`} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Check, Trash2 } from 'lucide-react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import MSwipeRow from './MSwipeRow';
|
||||
|
||||
vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() }));
|
||||
|
||||
/** jsdom has no Touch to build a TouchEvent from: an Event with the touches set on it, as MEdgeBack's test does. */
|
||||
function touch(el: Element, type: 'touchstart' | 'touchmove' | 'touchend', x = 0, y = 100) {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(e, 'touches', { value: type === 'touchend' ? [] : [{ clientX: x, clientY: y }] });
|
||||
act(() => {
|
||||
el.dispatchEvent(e);
|
||||
});
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const onDelete = vi.fn();
|
||||
const onDone = vi.fn();
|
||||
const onRowClick = vi.fn();
|
||||
render(
|
||||
<MSwipeRow
|
||||
leading={{ label: 'Done', icon: Check, tone: 'accent', onAction: onDone }}
|
||||
trailing={[{ label: 'Delete', icon: Trash2, tone: 'danger', onAction: onDelete }]}
|
||||
>
|
||||
<button type="button" onClick={onRowClick}>
|
||||
Sunscreen
|
||||
</button>
|
||||
</MSwipeRow>
|
||||
);
|
||||
const row = screen.getByText('Sunscreen').closest('div')?.parentElement as HTMLElement;
|
||||
return { row, onDelete, onDone, onRowClick };
|
||||
}
|
||||
|
||||
describe('MSwipeRow', () => {
|
||||
it('FE-MOB-SWIPE-001: shows no action buttons until the row is swiped', () => {
|
||||
setup();
|
||||
expect(screen.queryByText('Delete')).toBeNull();
|
||||
expect(screen.queryByText('Done')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SWIPE-002: a swipe towards the start opens the trailing action, a tap on it runs it', () => {
|
||||
const { row, onDelete } = setup();
|
||||
touch(row, 'touchstart', 200);
|
||||
touch(row, 'touchmove', 160);
|
||||
touch(row, 'touchmove', 120);
|
||||
touch(row, 'touchend');
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('FE-MOB-SWIPE-003: a mostly vertical drag is a scroll and opens nothing', () => {
|
||||
const { row } = setup();
|
||||
touch(row, 'touchstart', 200, 100);
|
||||
touch(row, 'touchmove', 190, 160);
|
||||
touch(row, 'touchend');
|
||||
expect(screen.queryByText('Delete')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SWIPE-004: a tap on an open row closes it instead of opening the row', () => {
|
||||
const { row, onRowClick } = setup();
|
||||
touch(row, 'touchstart', 200);
|
||||
touch(row, 'touchmove', 160);
|
||||
touch(row, 'touchmove', 120);
|
||||
touch(row, 'touchend');
|
||||
fireEvent.click(screen.getByText('Sunscreen'));
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SWIPE-005: a swipe that starts at the screen edge is left to the back gesture', () => {
|
||||
const { row } = setup();
|
||||
touch(row, 'touchstart', 10);
|
||||
touch(row, 'touchmove', 120);
|
||||
touch(row, 'touchend');
|
||||
expect(screen.queryByText('Done')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SWIPE-006: swiped through past most of the row, the first action runs at once', () => {
|
||||
const { row, onDelete } = setup();
|
||||
row.getBoundingClientRect = () => ({
|
||||
width: 360,
|
||||
height: 48,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 360,
|
||||
bottom: 48,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
touch(row, 'touchstart', 330);
|
||||
touch(row, 'touchmove', 300);
|
||||
touch(row, 'touchmove', 60);
|
||||
touch(row, 'touchend');
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { tickHaptic } from '../../native/haptics';
|
||||
|
||||
/**
|
||||
* A list row that swipes sideways to show its actions, as in Mail or Reminders.
|
||||
*
|
||||
* Swiped towards the start it shows the trailing actions (delete, in red), the
|
||||
* other way the leading one (pack, done). Let go past half of them and the row
|
||||
* stays open on its buttons; swipe through to the far end and the first action
|
||||
* runs straight away, with a tick when the finger crosses that point. A tap
|
||||
* anywhere closes an open row, and opening one closes the one open before.
|
||||
*
|
||||
* The gesture claims only a clearly sideways drag, so the list still scrolls,
|
||||
* and none that starts at the screen's left edge, which goes back (MEdgeBack).
|
||||
* Every action here is a second way to something the row already offers, so
|
||||
* nothing is lost to a screen reader or a mouse: the buttons are hidden from
|
||||
* both until the row is open.
|
||||
*/
|
||||
|
||||
export interface SwipeAction {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
tone: 'danger' | 'accent' | 'neutral';
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
interface MSwipeRowProps {
|
||||
/** Shown by a swipe towards the end of the line (to the right in LTR). */
|
||||
leading?: SwipeAction;
|
||||
/** Shown by a swipe towards the start; the first one is what a full swipe runs. */
|
||||
trailing?: SwipeAction[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** The row's own background: it covers the buttons until the row moves, so it must match what is around it. */
|
||||
surface?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BUTTON_W = 76;
|
||||
const EDGE_PX = 24;
|
||||
const CLAIM_PX = 10;
|
||||
const AXIS_BIAS = 1.5;
|
||||
/** Past this share of the row's width a release runs the first action. */
|
||||
const FULL_SHARE = 0.62;
|
||||
|
||||
const TONE: Record<SwipeAction['tone'], string> = {
|
||||
danger: 'bg-danger text-[color:var(--danger-text)]',
|
||||
accent: 'bg-m-act text-m-actfg',
|
||||
neutral: 'bg-[color:var(--bg-inverse)] text-[color:var(--text-inverse)]',
|
||||
};
|
||||
|
||||
// The row open now, so opening another closes it.
|
||||
let closeOpenRow: (() => void) | null = null;
|
||||
|
||||
export default function MSwipeRow({
|
||||
leading,
|
||||
trailing = [],
|
||||
disabled = false,
|
||||
className = '',
|
||||
surface = 'bg-[color:var(--m-card)]',
|
||||
children,
|
||||
}: MSwipeRowProps) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState<'leading' | 'trailing' | null>(null);
|
||||
// Which side shows while the finger moves, so the right buttons sit under the row.
|
||||
const [side, setSide] = useState<'leading' | 'trailing' | null>(null);
|
||||
const latest = useRef({ leading, trailing });
|
||||
useEffect(() => {
|
||||
latest.current = { leading, trailing };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!row || !content || disabled) return;
|
||||
const rtl = () => getComputedStyle(row).direction === 'rtl';
|
||||
let start: { x: number; y: number } | null = null;
|
||||
let claimed = false;
|
||||
let base = 0;
|
||||
let offset = 0;
|
||||
let beyond = false;
|
||||
|
||||
const place = (x: number, animate: boolean) => {
|
||||
offset = x;
|
||||
content.style.transition = animate ? 'transform 0.28s cubic-bezier(0.32, 0.72, 0, 1)' : 'none';
|
||||
content.style.transform = x ? `translateX(${x}px)` : '';
|
||||
};
|
||||
const width = () => row.getBoundingClientRect().width;
|
||||
// Positive offset reveals the leading side in LTR; RTL mirrors it.
|
||||
const leadSign = () => (rtl() ? -1 : 1);
|
||||
const openWidth = (which: 'leading' | 'trailing') =>
|
||||
which === 'leading' ? (latest.current.leading ? BUTTON_W : 0) : latest.current.trailing.length * BUTTON_W;
|
||||
|
||||
const close = () => {
|
||||
place(0, true);
|
||||
setOpen(null);
|
||||
setSide(null);
|
||||
if (closeOpenRow === close) closeOpenRow = null;
|
||||
};
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
claimed = false;
|
||||
start = null;
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
if (t.clientX < EDGE_PX) return;
|
||||
start = { x: t.clientX, y: t.clientY };
|
||||
base = offset;
|
||||
beyond = false;
|
||||
};
|
||||
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (!start) return;
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - start.x;
|
||||
const dy = t.clientY - start.y;
|
||||
if (!claimed) {
|
||||
if (Math.abs(dx) < CLAIM_PX && Math.abs(dy) < CLAIM_PX) return;
|
||||
if (Math.abs(dx) < Math.abs(dy) * AXIS_BIAS) {
|
||||
start = null;
|
||||
return;
|
||||
}
|
||||
claimed = true;
|
||||
if (closeOpenRow && closeOpenRow !== close) closeOpenRow();
|
||||
}
|
||||
if (e.cancelable) e.preventDefault();
|
||||
let x = base + dx;
|
||||
const which = x * leadSign() > 0 ? 'leading' : 'trailing';
|
||||
const room = openWidth(which);
|
||||
if (!room) x = x / 4;
|
||||
setSide((s) => (s === which ? s : which));
|
||||
// Unmeasured (width 0) means no full swipe: a row never runs an action by accident.
|
||||
const w = width();
|
||||
const crossed = w > 0 && room > 0 && Math.abs(x) > w * FULL_SHARE;
|
||||
if (crossed !== beyond) {
|
||||
beyond = crossed;
|
||||
if (crossed) tickHaptic();
|
||||
}
|
||||
place(x, false);
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
if (!start || !claimed) {
|
||||
start = null;
|
||||
return;
|
||||
}
|
||||
start = null;
|
||||
claimed = false;
|
||||
const which = offset * leadSign() > 0 ? 'leading' : 'trailing';
|
||||
const room = openWidth(which);
|
||||
const action = which === 'leading' ? latest.current.leading : latest.current.trailing[0];
|
||||
if (beyond && action) {
|
||||
close();
|
||||
action.onAction();
|
||||
return;
|
||||
}
|
||||
if (room && Math.abs(offset) > room / 2) {
|
||||
place(room * (which === 'leading' ? leadSign() : -leadSign()), true);
|
||||
setOpen(which);
|
||||
closeOpenRow = close;
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
// A tap on an open row closes it instead of opening what is under the finger.
|
||||
const onClickCapture = (e: MouseEvent) => {
|
||||
if (offset === 0) return;
|
||||
if (e.target instanceof Element && e.target.closest('[data-swipe-action]')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
};
|
||||
|
||||
row.addEventListener('touchstart', onStart, { passive: true });
|
||||
row.addEventListener('touchmove', onMove, { passive: false });
|
||||
row.addEventListener('touchend', onEnd, { passive: true });
|
||||
row.addEventListener('touchcancel', onEnd, { passive: true });
|
||||
row.addEventListener('click', onClickCapture, true);
|
||||
return () => {
|
||||
row.removeEventListener('touchstart', onStart);
|
||||
row.removeEventListener('touchmove', onMove);
|
||||
row.removeEventListener('touchend', onEnd);
|
||||
row.removeEventListener('touchcancel', onEnd);
|
||||
row.removeEventListener('click', onClickCapture, true);
|
||||
if (closeOpenRow === close) closeOpenRow = null;
|
||||
};
|
||||
}, [disabled]);
|
||||
|
||||
const run = (action: SwipeAction) => {
|
||||
closeOpenRow?.();
|
||||
action.onAction();
|
||||
};
|
||||
const shown = open ?? side;
|
||||
const button = (action: SwipeAction) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
data-swipe-action=""
|
||||
tabIndex={open ? 0 : -1}
|
||||
onClick={() => run(action)}
|
||||
className={`flex h-full w-[76px] flex-none flex-col items-center justify-center gap-1 text-caption font-semibold ${TONE[action.tone]}`}
|
||||
>
|
||||
<action.icon size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={rowRef} className={`relative overflow-hidden ${className}`}>
|
||||
{shown && (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={`absolute inset-0 flex ${shown === 'leading' ? 'justify-start' : 'justify-end'}`}
|
||||
>
|
||||
{shown === 'leading' ? leading && button(leading) : trailing.map(button)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={contentRef} className={`relative ${surface}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,12 +61,14 @@ describe('MWalletButton', () => {
|
||||
await waitFor(() => expect(onError).toHaveBeenLastCalledWith(expect.stringMatching(/could not be made/i)));
|
||||
});
|
||||
|
||||
it('on Android adds the booking to Google Wallet, under its own name', async () => {
|
||||
it('on Android adds the booking to Google Wallet, under its own name for screen readers', async () => {
|
||||
wallet.kind = 'google';
|
||||
wallet.addGoogle.mockResolvedValue('added');
|
||||
const onError = vi.fn();
|
||||
render(<MWalletButton tripId={42} reservationId={7} onError={onError} />);
|
||||
expect(screen.getByRole('button')).toHaveTextContent(/Google Wallet/);
|
||||
// The label is short, the accessible name says which wallet.
|
||||
expect(screen.getByRole('button')).toHaveTextContent(/^Wallet$/);
|
||||
expect(screen.getByRole('button', { name: /Google Wallet/ })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
await waitFor(() => expect(wallet.addGoogle).toHaveBeenCalledWith(42, 7, expect.any(String)));
|
||||
expect(wallet.add).not.toHaveBeenCalled();
|
||||
|
||||
@@ -57,10 +57,11 @@ export default function MWalletButton({ tripId, reservationId, onError, classNam
|
||||
onClick={() => void add()}
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
aria-label={t(wallet === 'google' ? 'reservations.wallet.addGoogle' : 'reservations.wallet.add')}
|
||||
className="flex items-center gap-[6px] rounded-full bg-black px-3 py-[7px] text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-white transition-transform active:scale-[.97] disabled:opacity-70" // theme-lint-disable: the wallets' badges are black in either theme
|
||||
>
|
||||
{busy ? <Loader2 size={13} className="animate-spin" /> : <Wallet size={13} strokeWidth={2} />}
|
||||
{t(wallet === 'google' ? 'reservations.wallet.addGoogle' : 'reservations.wallet.add')}
|
||||
{t('reservations.wallet.short')}
|
||||
</button>
|
||||
);
|
||||
return className ? <div className={className}>{button}</div> : button;
|
||||
|
||||
@@ -9,6 +9,14 @@ interface Page {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages kept either side of the selection. Two, not one: a second swipe that
|
||||
* starts while the first is still snapping has to find a page beyond the
|
||||
* neighbour, or it runs into the end of the strip and springs back instead of
|
||||
* reaching the day after.
|
||||
*/
|
||||
const REACH = 2;
|
||||
|
||||
/** The window of pages around the selection. A selection that points at no
|
||||
* day (the whole-trip view, or a day deleted remotely) still gets its page,
|
||||
* with the first day next to it as a way back. */
|
||||
@@ -21,11 +29,7 @@ export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[
|
||||
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []),
|
||||
];
|
||||
}
|
||||
const pages: Page[] = [];
|
||||
const prev = days[i - 1];
|
||||
const next = days[i + 1];
|
||||
if (prev) pages.push({ key: `d${prev.id}`, dayId: prev.id, active: false });
|
||||
pages.push({ key: `d${days[i].id}`, dayId: days[i].id, active: true });
|
||||
if (next) pages.push({ key: `d${next.id}`, dayId: next.id, active: false });
|
||||
return pages;
|
||||
return days
|
||||
.slice(Math.max(0, i - REACH), i + REACH + 1)
|
||||
.map((d) => ({ key: `d${d.id}`, dayId: d.id, active: d.id === days[i].id }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { pressHaptic } from '../../native/haptics';
|
||||
import { useLongPress } from './useLongPress';
|
||||
|
||||
vi.mock('../../native/haptics', () => ({ pressHaptic: vi.fn() }));
|
||||
|
||||
function Card({ onLongPress, onClick }: { onLongPress: () => void; onClick: () => void }) {
|
||||
const press = useLongPress(onLongPress);
|
||||
return (
|
||||
<div {...press} role="button" tabIndex={0} onClick={onClick}>
|
||||
Lisbon
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
/** jsdom has no Touch to build a TouchEvent from: an Event with the touches set on it, as MEdgeBack's test does. */
|
||||
function touch(el: Element, type: 'touchstart' | 'touchmove' | 'touchend', x = 50, y = 50) {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(e, 'touches', { value: type === 'touchend' ? [] : [{ clientX: x, clientY: y }] });
|
||||
act(() => {
|
||||
el.dispatchEvent(e);
|
||||
});
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const onLongPress = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
render(<Card onLongPress={onLongPress} onClick={onClick} />);
|
||||
return { card: screen.getByRole('button'), onLongPress, onClick };
|
||||
}
|
||||
|
||||
describe('useLongPress', () => {
|
||||
it('FE-MOB-LONGPRESS-001: a held finger lifts the card, bumps and opens the menu, and the tap that ends it opens nothing', () => {
|
||||
const { card, onLongPress, onClick } = setup();
|
||||
touch(card, 'touchstart');
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(card.hasAttribute('data-pressing')).toBe(true);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(onLongPress).toHaveBeenCalledOnce();
|
||||
expect(pressHaptic).toHaveBeenCalled();
|
||||
expect(card.hasAttribute('data-pressing')).toBe(false);
|
||||
touch(card, 'touchend');
|
||||
fireEvent.click(card);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-LONGPRESS-002: a finger that moves is scrolling and cancels it', () => {
|
||||
const { card, onLongPress } = setup();
|
||||
touch(card, 'touchstart');
|
||||
touch(card, 'touchmove', 50, 80);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-LONGPRESS-003: a short tap is a tap', () => {
|
||||
const { card, onLongPress, onClick } = setup();
|
||||
touch(card, 'touchstart');
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
touch(card, 'touchend');
|
||||
fireEvent.click(card);
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("FE-MOB-LONGPRESS-004: Android's contextmenu counts as the long press", () => {
|
||||
const { card, onLongPress } = setup();
|
||||
fireEvent.contextMenu(card);
|
||||
expect(onLongPress).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { type MouseEvent, type TouchEvent, useEffect, useRef } from 'react';
|
||||
import { pressHaptic } from '../../native/haptics';
|
||||
|
||||
/**
|
||||
* A long press on a card, as the phone knows it: hold still for a moment and
|
||||
* the card lifts, the phone bumps, and `onLongPress` opens its menu. A finger
|
||||
* that moves is scrolling and cancels it; the tap that ends a long press opens
|
||||
* nothing else. Android also reports the press as a contextmenu event, which
|
||||
* counts as the same press.
|
||||
*
|
||||
* Spread the returned props on the card. For the first moments it presses like
|
||||
* any tap; once the hold is clearly a hold the card gets data-pressing and lifts
|
||||
* (index.css), and the press dimming lets go of it.
|
||||
*/
|
||||
|
||||
const LIFT_MS = 180;
|
||||
const HOLD_MS = 450;
|
||||
const SLOP_PX = 8;
|
||||
|
||||
export function useLongPress(onLongPress: (() => void) | undefined) {
|
||||
const timer = useRef<number | null>(null);
|
||||
const lift = useRef<number | null>(null);
|
||||
const start = useRef<{ x: number; y: number } | null>(null);
|
||||
// The click that ends a long press, swallowed.
|
||||
const fired = useRef(false);
|
||||
const target = useRef<HTMLElement | null>(null);
|
||||
const handler = useRef(onLongPress);
|
||||
useEffect(() => {
|
||||
handler.current = onLongPress;
|
||||
});
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
if (lift.current) window.clearTimeout(lift.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const settle = () => {
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
if (lift.current) window.clearTimeout(lift.current);
|
||||
timer.current = null;
|
||||
lift.current = null;
|
||||
start.current = null;
|
||||
target.current?.removeAttribute('data-pressing');
|
||||
};
|
||||
const fire = () => {
|
||||
settle();
|
||||
fired.current = true;
|
||||
pressHaptic();
|
||||
handler.current?.();
|
||||
};
|
||||
|
||||
if (!onLongPress) return {};
|
||||
return {
|
||||
'data-long-press': '',
|
||||
onTouchStart: (e: TouchEvent<HTMLElement>) => {
|
||||
fired.current = false;
|
||||
if (e.touches.length !== 1) return;
|
||||
start.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
const card = e.currentTarget;
|
||||
target.current = card;
|
||||
lift.current = window.setTimeout(() => card.setAttribute('data-pressing', ''), LIFT_MS);
|
||||
timer.current = window.setTimeout(fire, HOLD_MS);
|
||||
},
|
||||
onTouchMove: (e: TouchEvent<HTMLElement>) => {
|
||||
if (!start.current) return;
|
||||
const t = e.touches[0];
|
||||
if (Math.abs(t.clientX - start.current.x) > SLOP_PX || Math.abs(t.clientY - start.current.y) > SLOP_PX) settle();
|
||||
},
|
||||
onTouchEnd: settle,
|
||||
onTouchCancel: settle,
|
||||
onContextMenu: (e: MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
if (!fired.current) fire();
|
||||
},
|
||||
onClickCapture: (e: MouseEvent<HTMLElement>) => {
|
||||
if (!fired.current) return;
|
||||
fired.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -637,6 +637,16 @@ html:has(.m-shell) {
|
||||
overflow: visible;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
/* In the iOS app the page bounces at its top and bottom, as every iOS list
|
||||
does; the fixed bars stay where they are. The rule above is for the browser,
|
||||
where a pull at the top reloads an installed PWA, which the app cannot do.
|
||||
Not on Android, whose stretch scales the bars too (MainActivity turns it
|
||||
off), not sideways, and not on a screen with pull-to-refresh, whose pull
|
||||
WebKit's bounce would race (mobile/usePullToRefresh.ts). */
|
||||
html[data-native-ios]:not([data-pull-refresh]),
|
||||
html[data-native-ios]:not([data-pull-refresh]):has(.m-shell) {
|
||||
overscroll-behavior-y: auto;
|
||||
}
|
||||
html:has(.m-shell) body {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
|
||||
@@ -18,7 +18,9 @@ import type { Location, NavigationType } from 'react-router';
|
||||
*/
|
||||
export type RouteTransitionKind = 'forward' | 'back' | 'tab';
|
||||
|
||||
const SETTLE_MS = 800;
|
||||
// Short on purpose: while it waits the browser shows a frozen snapshot of the old
|
||||
// page, so on a slow connection a longer wait reads as the app hanging.
|
||||
const SETTLE_MS = 350;
|
||||
let skipNext = false;
|
||||
let current: ViewTransition | null = null;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from '../../../i18n'
|
||||
import { useIsDark } from '../../../hooks/useIsDark'
|
||||
import {
|
||||
Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||
Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Settings2, ChevronRight,
|
||||
Sparkles, Luggage, Plane, Bookmark, Users, Mountain, CarFront, Settings2, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
||||
import AirTrailIcon from '../../../components/shared/AirTrailIcon'
|
||||
@@ -23,6 +23,7 @@ import { useAiAddonTile } from '../../../components/Admin/addons/useAiAddonTile'
|
||||
import { ImmichIcon, SynologyIcon } from '../../../components/Admin/addons/PhotoProviderIcons'
|
||||
import { MAdminCard, MAdminClampedText } from './MAdminUi'
|
||||
import WandererMark from '../../../components/Tours/wanderer/WandererMark'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
// Keys are the `icon` column from the addons table; anything unknown falls back to
|
||||
// Puzzle. Users/Sparkles cover collab and llm_parsing, as on the desktop.
|
||||
@@ -70,9 +71,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 size={22} className="animate-spin text-m-faint" />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={5} />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../../../compon
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import { MAdminButton, MAdminCard, MAdminCardHead } from './MAdminUi'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
export default function MAdminCategoryManager() {
|
||||
const {
|
||||
@@ -126,9 +127,7 @@ export default function MAdminCategoryManager() {
|
||||
{showForm && <div className="mb-3 mt-1">{categoryForm}</div>}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-m-faint">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-[color:var(--m-ink)]" />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={5} />
|
||||
) : categories.length === 0 ? (
|
||||
<div className="py-8 text-center text-m-faint">
|
||||
<p className="text-[0.8125rem]">{t('categories.empty')}</p>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useGithubReleases } from '../../../components/Admin/useGithubReleases'
|
||||
import { escapeReleaseHtml as escapeHtml, parseReleaseNotes } from '../../../utils/releaseNotes'
|
||||
import { MAdminButton, MAdminCard } from './MAdminUi'
|
||||
import { withoutDonationsInApp } from '../../../utils/donations'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
const REPO = 'liketrek/TREK'
|
||||
|
||||
@@ -156,9 +157,7 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea
|
||||
{/* Loading / Error / Releases */}
|
||||
{loading ? (
|
||||
<MAdminCard>
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-m-muted" />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={4} framed={false} />
|
||||
</MAdminCard>
|
||||
) : error && releases.length === 0 ? (
|
||||
<MAdminCard>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
|
||||
import { Key, Trash2, User, Shield } from 'lucide-react'
|
||||
import { SCOPES_PREVIEW, useMcpTokensAdmin } from '../../../components/Admin/useMcpTokensAdmin'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useFormatDate } from '../../../hooks/useDateFormat'
|
||||
import { MAdminCard } from './MAdminUi'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
// Mobile MCP tokens admin section: OAuth sessions + long-lived MCP tokens, each
|
||||
// as a card list with loading/empty states, expandable scope chips and a
|
||||
@@ -34,9 +35,7 @@ export default function MAdminMcpTokensPanel() {
|
||||
</div>
|
||||
<MAdminCard>
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-m-faint" />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={3} framed={false} />
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10">
|
||||
<Shield className="h-8 w-8 text-m-faint" />
|
||||
@@ -114,9 +113,7 @@ export default function MAdminMcpTokensPanel() {
|
||||
</div>
|
||||
<MAdminCard>
|
||||
{tokensLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-m-faint" />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={3} framed={false} />
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10">
|
||||
<Key className="h-8 w-8 text-m-faint" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from '../../../i18n'
|
||||
import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
|
||||
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminInput } from './MAdminUi'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
// Small round icon action button in the mobile admin idiom (flat --m-ic circle).
|
||||
function PkIconBtn({
|
||||
@@ -96,9 +97,7 @@ export default function MAdminPackingTemplateManager() {
|
||||
|
||||
{/* Template list */}
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<span className="inline-block h-7 w-7 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-[color:var(--m-ink)]" />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={4} />
|
||||
) : templates.length === 0 ? (
|
||||
<div className="py-8 text-center font-geist text-[0.75rem] text-m-faint">{t('admin.packingTemplates.empty')}</div>
|
||||
) : (
|
||||
|
||||
@@ -197,6 +197,7 @@ function CatalogSearch({ admin }: { admin: Admin }) {
|
||||
onChange={(e) => search.setQuery(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t('admin.paddleRegions.search')}
|
||||
enterKeyHint="search"
|
||||
placeholder={t('admin.paddleRegions.searchPlaceholder')}
|
||||
className="h-[42px] w-full rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] pe-3 ps-[34px] text-[length:calc(13px*var(--fs-scale-body,1))] text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PermissionLevel } from '../../../store/permissionsStore'
|
||||
import { CATEGORIES, LEVEL_LABELS, usePermissionsAdmin } from '../../../components/Admin/usePermissionsAdmin'
|
||||
import { Save, RotateCcw } from 'lucide-react'
|
||||
import { MAdminButton, MAdminCard } from './MAdminUi'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
export default function MAdminPermissionsPanel(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
@@ -12,9 +13,7 @@ export default function MAdminPermissionsPanel(): React.ReactElement {
|
||||
if (loading) {
|
||||
return (
|
||||
<MAdminCard>
|
||||
<div className="flex justify-center py-6">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-[color:var(--m-ink)]" />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={5} framed={false} />
|
||||
</MAdminCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function MAdminPluginsPanel() {
|
||||
<Search size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-m-faint" />
|
||||
<input
|
||||
value={q} onChange={e => setQ(e.target.value)} type="search"
|
||||
placeholder={t('admin.plugins.searchPlaceholder')}
|
||||
enterKeyHint="search" placeholder={t('admin.plugins.searchPlaceholder')}
|
||||
className="h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] ps-9 pe-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import MSheet from '../../components/MSheet'
|
||||
import MChip from '../../components/MChip'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import { MAdminButton, MAdminCard, MAdminField, MAdminSheetFrame } from './MAdminUi'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
interface MAdminUsersSectionProps {
|
||||
admin: ReturnType<typeof useAdmin>
|
||||
@@ -52,9 +53,7 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-[color:var(--m-ink)]" />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={5} framed={false} />
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<button
|
||||
|
||||
@@ -14,6 +14,7 @@ import DawarichAtlasDialog from '../../../components/Dawarich/DawarichAtlasDialo
|
||||
import { useAddonStore } from '../../../store/addonStore'
|
||||
import { countryStatus } from '../../../pages/atlas/atlasModel'
|
||||
import AtlasCountryPlaces from '../../../components/Atlas/AtlasCountryPlaces'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
/** The atlas header's buttons: one shape, equal widths, the phone shell's glass. */
|
||||
const HEADER_BUTTON = 'flex h-[38px] min-w-0 flex-1 items-center justify-center gap-2 rounded-full border px-3 text-[0.78125rem] font-bold transition-transform active:scale-[.97]'
|
||||
@@ -113,9 +114,7 @@ export default function MAtlas() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative h-dvh">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-ink" />
|
||||
</div>
|
||||
<MSkeleton variant="map" className="absolute inset-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function MAtlasSearch({
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'Enter' && rows.length > 0) onSelect(rows[0].code)
|
||||
}}
|
||||
placeholder={t('atlas.searchCountry')}
|
||||
enterKeyHint="search" placeholder={t('atlas.searchCountry')}
|
||||
className="min-w-0 flex-1 bg-transparent font-geist text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:font-normal placeholder:text-m-faint"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void search() } }}
|
||||
placeholder={t('collections.addPlaceSearch')}
|
||||
enterKeyHint="search" placeholder={t('collections.addPlaceSearch')}
|
||||
className="min-w-0 flex-1 bg-transparent font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function MCollEditSheet({ target, onClose, onCreated, onRequestDe
|
||||
value={coverQuery}
|
||||
onChange={e => setCoverQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void searchCover() } }}
|
||||
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
enterKeyHint="search" placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
className={`${INPUT_CLS} min-w-0 flex-1`}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -19,6 +19,7 @@ import { tourDayRowIcon } from '../../../components/Tours/tourDayRowIcon';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import MSheet from '../../components/MSheet';
|
||||
import MSkeleton from '../../components/MSkeleton';
|
||||
import { CancelPill, Eyebrow, PrimaryPill, SheetFooter, SheetHeader } from './MCollSheetKit';
|
||||
|
||||
/** A kept tour's measures as the small pills of the phone's rows. */
|
||||
@@ -146,12 +147,7 @@ function PointsChips({ takeOver }: { takeOver: CollectionTourTakeOver }) {
|
||||
/** The sheet's body while it asks: the trips, the points and the name in the trip. */
|
||||
function TakeOverForm({ takeOver }: { takeOver: CollectionTourTakeOver }) {
|
||||
const { t } = useTranslation();
|
||||
if (takeOver.detailStatus === 'loading')
|
||||
return (
|
||||
<div className="flex justify-center py-10 text-m-faint">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
if (takeOver.detailStatus === 'loading') return <MSkeleton variant="rows" count={3} className="py-2" />;
|
||||
if (takeOver.detailStatus === 'offline' || takeOver.detailStatus === 'error')
|
||||
return (
|
||||
<div className="py-6 text-center font-geist text-m-muted" style={fs(12.5)}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranslationFn } from '../../../types'
|
||||
import { useTripCopyPicker } from '../../../components/Collections/useTripCopyPicker'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import { SheetHeader } from './MCollSheetKit'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
interface MCollTripPickerSheetProps {
|
||||
open: boolean
|
||||
@@ -32,14 +33,12 @@ export default function MCollTripPickerSheet({ open, count, onCopy, onClose, t }
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('collections.copyToTripSearch')}
|
||||
enterKeyHint="search" placeholder={t('collections.copyToTripSearch')}
|
||||
className="min-w-0 flex-1 bg-transparent font-geist text-[0.78125rem] text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-m-faint">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={4} />
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-10 text-center font-geist text-[0.78125rem] text-m-faint">{t('collections.noTrips')}</div>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import {
|
||||
Bookmark, Check, CheckCheck, CheckSquare, ChevronDown, Copy, CopyPlus,
|
||||
FolderInput, Layers, List, Loader2, Map as MapIcon, Pencil, Plus, Search, Share2,
|
||||
FolderInput, Layers, List, Map as MapIcon, Pencil, Plus, Search, Share2,
|
||||
Tag, Tags, Trash2, X,
|
||||
} from 'lucide-react'
|
||||
import type { CollectionStatus } from '@trek/shared'
|
||||
@@ -27,6 +27,7 @@ import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import MCollListNav from './MCollListNav'
|
||||
import { useIsSplit } from '../../../hooks/sizeClass'
|
||||
import { skipNextRouteTransition } from '../../routeTransition'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
const HEADER_CIRCLE =
|
||||
'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]'
|
||||
@@ -233,9 +234,7 @@ export default function MCollections() {
|
||||
<MCollInvites invites={c.orderedInvites} onAccept={c.handleAcceptInvite} onDecline={c.handleDeclineInvite} t={t} />
|
||||
|
||||
{c.loading && c.collections.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20 text-m-faint">
|
||||
<Loader2 size={22} className="animate-spin" />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={5} className="pt-2" />
|
||||
) : noLists ? (
|
||||
<div className="mt-6">
|
||||
{/* The dock's "+" starts the first list (see the ?create= handoff above). */}
|
||||
@@ -271,7 +270,7 @@ export default function MCollections() {
|
||||
<input
|
||||
value={c.search}
|
||||
onChange={e => c.setSearch(e.target.value)}
|
||||
placeholder={t('collections.search')}
|
||||
enterKeyHint="search" placeholder={t('collections.search')}
|
||||
className="min-w-0 flex-1 bg-transparent font-geist text-[0.78125rem] text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
</div>
|
||||
@@ -432,9 +431,7 @@ export default function MCollections() {
|
||||
{c.view === 'list' && <MCollTours search={c.search} />}
|
||||
{c.view === 'list' && (
|
||||
c.placesLoading && !hasPlaces ? (
|
||||
<div className="flex items-center justify-center py-16 text-m-faint">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={5} className="pt-2" />
|
||||
) : !hasPlaces ? (
|
||||
<EmptyNote scene="collections" title={t('collections.empty.title')} />
|
||||
) : c.visiblePlaces.length === 0 ? (
|
||||
|
||||
@@ -24,6 +24,8 @@ import MSegmented from '../../components/MSegmented'
|
||||
import MUserMenu from './MUserMenu'
|
||||
import { useMobileDashOrder, useMobileDashVisibility, MobileDashWidget } from './MDashWidgets'
|
||||
import MNewTripSheet from './MNewTripSheet'
|
||||
import MContextMenu from '../../components/MContextMenu'
|
||||
import { useLongPress } from '../../components/useLongPress'
|
||||
import type { MobileDashToken } from '@trek/shared'
|
||||
import { usePullToRefresh } from '../../usePullToRefresh'
|
||||
import MPullIndicator from '../../components/MPullIndicator'
|
||||
@@ -59,6 +61,9 @@ export default function MDashboard(): React.ReactElement {
|
||||
const unread = useInAppNotificationStore(s => s.unreadCount)
|
||||
const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// The trip a long press opened the menu for; kept while the menu slides away.
|
||||
const [heldTrip, setHeldTrip] = useState<{ trip: DashboardTrip; open: boolean } | null>(null)
|
||||
const holdTrip = (trip: DashboardTrip) => setHeldTrip({ trip, open: true })
|
||||
const { available: assistantAvailable, open: openAssistant } = useAssistantHomeLauncher()
|
||||
const [subOpen, setSubOpen] = useState(false)
|
||||
// The filter chips scroll when a language's words do not fit beside the two
|
||||
@@ -194,6 +199,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
pluginBadges={badgesFor(trip.id)}
|
||||
actions={actionsFor(trip, 'grid')}
|
||||
onOpen={() => navigate(`/trips/${trip.id}`)}
|
||||
onLongPress={() => holdTrip(trip)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -209,6 +215,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
pluginBadges={badgesFor(trip.id)}
|
||||
actions={actionsFor(trip, 'list')}
|
||||
onOpen={() => navigate(`/trips/${trip.id}`)}
|
||||
onLongPress={() => holdTrip(trip)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -291,6 +298,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
t={t}
|
||||
onOpen={() => navigate(`/trips/${spotlight.id}`)}
|
||||
actions={actionsFor(spotlight, 'list')}
|
||||
onLongPress={() => holdTrip(spotlight)}
|
||||
/>
|
||||
)}
|
||||
<div style={{ order: dashOrder.indexOf('trips') }}>{renderBlock('trips')}</div>
|
||||
@@ -308,6 +316,12 @@ export default function MDashboard(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MContextMenu
|
||||
open={!!heldTrip?.open}
|
||||
title={heldTrip?.trip.title ?? ''}
|
||||
actions={heldTrip ? actionsFor(heldTrip.trip, 'list') : []}
|
||||
onClose={() => setHeldTrip(h => (h ? { ...h, open: false } : h))}
|
||||
/>
|
||||
<MNewTripSheet
|
||||
open={showForm}
|
||||
trip={editingTrip}
|
||||
@@ -350,12 +364,14 @@ export default function MDashboard(): React.ReactElement {
|
||||
}
|
||||
|
||||
// ── Spotlight ────────────────────────────────────────────────────────────────
|
||||
function MSpotlightCard({ trip, t, onOpen, actions }: {
|
||||
function MSpotlightCard({ trip, t, onOpen, actions, onLongPress }: {
|
||||
trip: DashboardTrip
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
onOpen: () => void
|
||||
actions: CardAction[]
|
||||
onLongPress?: () => void
|
||||
}): React.ReactElement {
|
||||
const press = useLongPress(onLongPress)
|
||||
const status = getTripStatus(trip)
|
||||
const ongoing = status === 'ongoing'
|
||||
const until = daysUntil(trip.start_date)
|
||||
@@ -390,6 +406,7 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
|
||||
|
||||
return (
|
||||
<div
|
||||
{...press}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
@@ -517,12 +534,14 @@ function coverStyle(trip: DashboardTrip): React.CSSProperties {
|
||||
: { backgroundImage: entityGradient(trip.id) }
|
||||
}
|
||||
|
||||
function MTripGridCard({ trip, locale, badge, pluginBadges, actions, onOpen }: {
|
||||
function MTripGridCard({ trip, locale, badge, pluginBadges, actions, onOpen, onLongPress }: {
|
||||
trip: DashboardTrip; locale: string; badge: string; pluginBadges: TripCardBadge[]
|
||||
actions: CardAction[]; onOpen: () => void
|
||||
actions: CardAction[]; onOpen: () => void; onLongPress?: () => void
|
||||
}): React.ReactElement {
|
||||
const press = useLongPress(onLongPress)
|
||||
return (
|
||||
<div
|
||||
{...press}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
@@ -550,13 +569,15 @@ function MTripGridCard({ trip, locale, badge, pluginBadges, actions, onOpen }: {
|
||||
)
|
||||
}
|
||||
|
||||
function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen }: {
|
||||
function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen, onLongPress }: {
|
||||
trip: DashboardTrip; locale: string
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
badge: string; pluginBadges: TripCardBadge[]; actions: CardAction[]; onOpen: () => void
|
||||
badge: string; pluginBadges: TripCardBadge[]; actions: CardAction[]; onOpen: () => void; onLongPress?: () => void
|
||||
}): React.ReactElement {
|
||||
const press = useLongPress(onLongPress)
|
||||
return (
|
||||
<div
|
||||
{...press}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
|
||||
@@ -413,7 +413,7 @@ function CoverField({ cover, title }: { cover: ReturnType<typeof useTripCoverPic
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
|
||||
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
enterKeyHint="search" placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
aria-label={t('dashboard.unsplashSearchPlaceholder')}
|
||||
className={SHEET_FIELD}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { usePullToRefresh } from '../../usePullToRefresh'
|
||||
import MPullIndicator from '../../components/MPullIndicator'
|
||||
import MGlassBar from '../../components/MGlassBar'
|
||||
import { useIsWide } from '../../../hooks/sizeClass'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
type JourneyListItem = Journey & {
|
||||
entry_count?: number
|
||||
@@ -56,9 +57,7 @@ export default function MJourney() {
|
||||
<MPullIndicator state={pull} label={t('common.loading')} />
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto px-4 pt-[calc(var(--m-safe-top,12px)+52px)] pb-[calc(var(--bottom-nav-h,84px)+16px)] wide:px-6 wide:pt-[calc(var(--m-safe-top,12px)+72px)] wide:[&>*]:mx-auto wide:[&>*]:max-w-[1040px]">
|
||||
{loading && list.length === 0 ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-ink" />
|
||||
</div>
|
||||
<MSkeleton variant="cards" count={3} />
|
||||
) : list.length === 0 ? (
|
||||
<div className="flex min-h-full flex-1 flex-col items-center justify-center px-6 py-10 text-center">
|
||||
<MDancingTrek scene="journey" className="mb-2" />
|
||||
|
||||
@@ -34,6 +34,7 @@ import MJourneyDayImportSheet from './MJourneyDayImportSheet'
|
||||
import MJourneyRecordingSheet from './MJourneyRecordingSheet'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTabHeadSlot } from '../trip/tabHeadSlot'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
/**
|
||||
* Journey detail — integrated map with the horizontal 280px card timeline
|
||||
@@ -315,9 +316,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
|
||||
if (loading || !current) {
|
||||
return (
|
||||
<div className="flex h-dvh items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-ink" />
|
||||
</div>
|
||||
<MSkeleton variant="detail" className="h-dvh overflow-hidden" />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -555,7 +555,7 @@ export default function MJourneyEntrySheet({
|
||||
readOnly={readOnly}
|
||||
onChange={e => searchLocation(e.target.value)}
|
||||
onFocus={() => { if (locationResults.length > 0) setShowLocationResults(true) }}
|
||||
placeholder={t('journey.editor.searchLocation')}
|
||||
enterKeyHint="search" placeholder={t('journey.editor.searchLocation')}
|
||||
className="min-w-0 flex-1 bg-transparent font-geist text-[0.75rem] text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
{locationLat != null && <MapPin size={13} className="flex-none text-m-muted" />}
|
||||
|
||||
@@ -11,6 +11,7 @@ import MSegmented from '../../components/MSegmented'
|
||||
import MNotificationRow from './MNotificationRow'
|
||||
import { usePullToRefresh } from '../../usePullToRefresh'
|
||||
import MPullIndicator from '../../components/MPullIndicator'
|
||||
import MSkeleton from '../../components/MSkeleton'
|
||||
|
||||
function Spinner({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
@@ -81,9 +82,7 @@ export default function MNotifications() {
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-[20px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-[14px]">
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex justify-center py-14">
|
||||
<Spinner size={24} />
|
||||
</div>
|
||||
<MSkeleton variant="rows" count={6} framed={false} />
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<MDancingTrek scene="notifications" className="mb-2" />
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import {
|
||||
removeClientCertificate,
|
||||
replaceClientCertificate,
|
||||
storedClientCertificate,
|
||||
} from '../../../native/clientCertificate';
|
||||
import { TrekShell } from '../../../native/trekShell';
|
||||
import MClientCertCard from './MClientCertCard';
|
||||
|
||||
vi.mock('../../../native/clientCertificate', () => ({
|
||||
storedClientCertificate: vi.fn(),
|
||||
replaceClientCertificate: vi.fn(),
|
||||
removeClientCertificate: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../native/trekShell', () => ({
|
||||
TrekShell: { getServer: vi.fn(async () => ({ url: 'https://trek.example.com' })), reload: vi.fn(async () => {}) },
|
||||
}));
|
||||
|
||||
const info = {
|
||||
host: 'trek.example.com',
|
||||
port: 443,
|
||||
subject: 'Phone of the household',
|
||||
issuer: 'Home CA',
|
||||
notAfter: Date.UTC(2028, 10, 8),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(storedClientCertificate).mockResolvedValue(info);
|
||||
});
|
||||
|
||||
describe('MClientCertCard', () => {
|
||||
it('stays away without a stored certificate', async () => {
|
||||
vi.mocked(storedClientCertificate).mockResolvedValue(null);
|
||||
const { container } = render(<MClientCertCard />);
|
||||
await waitFor(() => expect(storedClientCertificate).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('shows whom the certificate names, who issued it and the server it is for', async () => {
|
||||
render(<MClientCertCard />);
|
||||
expect(await screen.findByText('Phone of the household')).toBeInTheDocument();
|
||||
expect(screen.getByText('Issued by Home CA')).toBeInTheDocument();
|
||||
expect(screen.getByText('trek.example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('replaces it for the server shown and reloads, or says why it could not', async () => {
|
||||
vi.mocked(replaceClientCertificate).mockResolvedValueOnce({ result: 'failed', failure: 'expired' });
|
||||
render(<MClientCertCard />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Replace' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('This certificate has expired.');
|
||||
expect(TrekShell.reload).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(replaceClientCertificate).mockResolvedValueOnce({ result: 'imported', certificate: info });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Replace' }));
|
||||
await waitFor(() => expect(TrekShell.reload).toHaveBeenCalledTimes(1));
|
||||
expect(replaceClientCertificate).toHaveBeenLastCalledWith(
|
||||
'https://trek.example.com',
|
||||
expect.objectContaining({ title: 'Certificate password' })
|
||||
);
|
||||
});
|
||||
|
||||
it('removes it only after the confirmation, then reloads', async () => {
|
||||
vi.mocked(removeClientCertificate).mockResolvedValueOnce(true);
|
||||
render(<MClientCertCard />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Remove' }));
|
||||
expect(removeClientCertificate).not.toHaveBeenCalled();
|
||||
const buttons = await screen.findAllByRole('button', { name: 'Remove' });
|
||||
fireEvent.click(buttons[buttons.length - 1]);
|
||||
await waitFor(() => expect(TrekShell.reload).toHaveBeenCalledTimes(1));
|
||||
expect(removeClientCertificate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { FileKey } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import {
|
||||
removeClientCertificate,
|
||||
replaceClientCertificate,
|
||||
storedClientCertificate,
|
||||
} from '../../../native/clientCertificate';
|
||||
import { TrekShell, type ClientCertificateFailure, type ClientCertificateInfo } from '../../../native/trekShell';
|
||||
import MConfirmSheet from './MConfirmSheet';
|
||||
import { MSetButton, MSetCard, MSetHint, MSetRow } from './MSettingsUi';
|
||||
|
||||
const FAILURE_KEYS: Record<ClientCertificateFailure, string> = {
|
||||
format: 'native.cert.errorFormat',
|
||||
no_key: 'native.cert.errorNoKey',
|
||||
expired: 'native.cert.errorExpired',
|
||||
not_yet_valid: 'native.cert.errorNotYetValid',
|
||||
store: 'native.cert.errorStore',
|
||||
};
|
||||
|
||||
/**
|
||||
* The client certificate the app shows to a server behind mutual TLS, inside
|
||||
* the app only and only once one is stored: someone without a proxy that asks
|
||||
* for one never needs to see this. Replacing or removing it reloads the server,
|
||||
* so the next connection uses what is stored now.
|
||||
*/
|
||||
export default function MClientCertCard() {
|
||||
const { t, locale } = useTranslation();
|
||||
const [certificate, setCertificate] = useState<ClientCertificateInfo | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void storedClientCertificate().then((found) => {
|
||||
if (live) setCertificate(found);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!certificate) return null;
|
||||
|
||||
const replace = async () => {
|
||||
setProblem(null);
|
||||
setBusy(true);
|
||||
const { url } = await TrekShell.getServer();
|
||||
const outcome = await replaceClientCertificate(url ?? `https://${certificate.host}:${certificate.port}`, {
|
||||
title: t('native.cert.passwordTitle'),
|
||||
message: t('native.cert.passwordMessage'),
|
||||
placeholder: t('native.cert.passwordPlaceholder'),
|
||||
ok: t('native.cert.passwordOk'),
|
||||
cancel: t('native.cert.passwordCancel'),
|
||||
wrongPassword: t('native.cert.passwordWrong'),
|
||||
});
|
||||
setBusy(false);
|
||||
if (outcome.result === 'imported') {
|
||||
await TrekShell.reload();
|
||||
} else if (outcome.result === 'failed') {
|
||||
setProblem(t(FAILURE_KEYS[outcome.failure ?? 'format']));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setConfirmRemove(false);
|
||||
if (await removeClientCertificate()) await TrekShell.reload();
|
||||
};
|
||||
|
||||
const validUntil = new Date(certificate.notAfter).toLocaleDateString(locale, { dateStyle: 'medium' });
|
||||
|
||||
return (
|
||||
<MSetCard title={t('native.cert.settingTitle')} icon={FileKey} className="mt-3">
|
||||
<div className="-mt-[4px]">
|
||||
<MSetRow
|
||||
first
|
||||
label={certificate.subject}
|
||||
sub={t('native.cert.settingIssuer', { issuer: certificate.issuer })}
|
||||
/>
|
||||
<MSetRow label={t('native.cert.settingServer')} sub={certificate.host} />
|
||||
<MSetRow label={t('native.cert.settingValidUntil')} sub={validUntil} />
|
||||
</div>
|
||||
<MSetHint>{t('native.cert.settingHint', { host: certificate.host })}</MSetHint>
|
||||
{problem && (
|
||||
<p role="alert" className="mt-2 text-caption text-[color:var(--m-st-danger)]">
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<MSetButton variant="ghost" disabled={busy} onClick={() => void replace()}>
|
||||
{t('native.cert.settingReplace')}
|
||||
</MSetButton>
|
||||
<MSetButton variant="danger" disabled={busy} onClick={() => setConfirmRemove(true)}>
|
||||
{t('native.cert.settingRemove')}
|
||||
</MSetButton>
|
||||
</div>
|
||||
<MConfirmSheet
|
||||
open={confirmRemove}
|
||||
onClose={() => setConfirmRemove(false)}
|
||||
title={t('native.cert.removeTitle')}
|
||||
message={t('native.cert.removeMessage', { host: certificate.host })}
|
||||
confirmLabel={t('native.cert.settingRemove')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
onConfirm={() => void remove()}
|
||||
/>
|
||||
</MSetCard>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { AlertTriangle, CircleHelp, Info } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import { useNativeQuestion } from '../../../native/useNativeQuestion'
|
||||
import { SHEET_DANGER, SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
|
||||
|
||||
interface MConfirmSheetProps {
|
||||
@@ -37,8 +38,18 @@ export default function MConfirmSheet({
|
||||
children,
|
||||
}: MConfirmSheetProps) {
|
||||
const Icon = danger ? AlertTriangle : onConfirm ? CircleHelp : Info
|
||||
// In the app a plain question goes to the system (native/nativeDialogs.ts); a
|
||||
// notice, or one with a field or a list in it, stays this card.
|
||||
const question =
|
||||
onConfirm && confirmLabel && !children && typeof message === 'string'
|
||||
? { title, message, confirmLabel, cancelLabel, danger }
|
||||
: null
|
||||
const html = useNativeQuestion(open, question, (yes) => {
|
||||
if (yes) onConfirm?.()
|
||||
onClose()
|
||||
})
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={title}>
|
||||
<MSheet open={open && html} onClose={onClose} variant="card" material="glass" ariaLabel={title}>
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pt-4">
|
||||
<span
|
||||
className={`flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] ${
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useTranslation } from '../../../i18n'
|
||||
import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
|
||||
import MConfirmSheet from './MConfirmSheet'
|
||||
import MAppLockCard from './MAppLockCard'
|
||||
import MClientCertCard from './MClientCertCard'
|
||||
import PasswordChecklist from '../../../components/shared/PasswordChecklist'
|
||||
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
|
||||
import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings'
|
||||
@@ -182,6 +183,8 @@ export default function MSettingsAccount() {
|
||||
{/* ── Two-factor authentication ───────────────────────────── */}
|
||||
{/* The app lock, inside the app only (Face ID, Touch ID or the passcode). */}
|
||||
<MAppLockCard />
|
||||
{/* The client certificate for a server behind mutual TLS, once one is stored. */}
|
||||
<MClientCertCard />
|
||||
|
||||
<MSetCard title={t('settings.mfa.title')} icon={KeyRound} className="mt-3">
|
||||
{mfaRequiredByPolicy && (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user