Compare commits

..

48 Commits

Author SHA1 Message Date
Maurice 5420077b98 fix(native): a proxy sign-in the address check never met loads in the app instead of the browser
A proxy that lets /api through (an Authelia or Authentik bypass for the app)
still sends the page itself to its sign-in. The app took that redirect for a
foreign link and handed it to the browser, so the sign-in happened there and
the app stayed black on every launch. A redirect away from the server while
its first page loads is now kept as the proxy's sign-in host, on Android and
iOS, and the iOS bridge guard is always in place for hosts learned that way.
2026-11-09 20:17:37 +01:00
github-actions[bot] 5cfc7a602a chore: bump version to 5.1.2 [skip ci] 2026-11-09 12:58:07 +00:00
Maurice b48e344749 test(planner): the booking dialog's map button reads Map 2026-11-09 13:17:22 +01:00
Maurice e58a880f34 fix(mobile): the detail sheet reads its edit permission off the live booking, not the held one 2026-11-09 13:07:50 +01:00
Maurice 17ff7f6bd8 fix(i18n): the short wallet label in the non-Latin locales is their own word for it
A Latin word in those scripts counts as English whatever it is, so
same-as-en is not honoured there.
2026-11-09 13:00:09 +01:00
Maurice f03bb97d50 fix(mobile): a booking opens its detail sheet first, as a transport and the desktop do; Wallet and Map buttons read short
Bookings in the Bookings tab, the day sheet, a place's sheet and the road trip
opened their editor straight away, while a transport showed its details with
a pencil to edit. Every booking now opens the same detail sheet; its edit and
delete follow the booking's own permission (reservation_edit for a stay, a
table or a ticket, day_edit for a transport). Notes keep opening for editing,
as on the desktop.

The wallet button reads "Wallet" (its accessible name still says which one),
and "On map" reads "Map" in every language.
2026-11-09 12:56:50 +01:00
Maurice ed0142be7d chore(client): list the client certificate card as a phone-only screen 2026-11-09 12:38:52 +01:00
Maurice 6e9f89a25f fix(map): a GL map follows its box, not only the window, so closing the plan pane on a tablet leaves no black band (#3023)
Mapbox GL measures its container on window resizes only. In the app on a
tablet the trip's plan pane closes beside the map without one: the box grew
to the full width while the canvas kept its old size, and the rest stayed
black. Every GL map built through buildGlMap now watches its container and
resizes once per frame while it changes; the observer goes with the map.

Closes #3023
2026-11-09 12:29:37 +01:00
Maurice a3c7c3d76a fix(memories): mark the Synology lookups and repair as independent writes for the transaction gate
Each photo's write follows its own network lookup, the repair's marker only
records that the pass ran, and a lookup may renew the Synology session on
its own; none of these can share one transaction.
2026-11-09 12:25:40 +01:00
Maurice c043ca4bba chore(notices): the release popup comes back for 5.1.2 2026-11-09 12:18:17 +01:00
Maurice 004c6212c0 fix(i18n): mark the client certificate strings that stay English on purpose
"Server" and Italian "Password" are the words those languages use; the
untranslated-strings ratchet counts them unless they carry same-as-en.
2026-11-09 12:14:38 +01:00
Maurice 07448e662c feat(native): client certificates for servers behind mutual TLS
A proxy in front of TREK (Cloudflare, nginx, Caddy, Traefik, HAProxy) can
let in only devices that show a client certificate. The apps could not, so
such a server was unusable from them.

- Address screen: the server check runs natively and notices when the
  server asks for a certificate. Instead of "no server answered" it says so
  and offers to add a .p12/.pfx file; a wrong password asks again, an
  expired, not yet valid or keyless file is named as such.
- The certificate is shown only to the host and port it was added for.
  iOS keeps it in the keychain (this device only), Android seals the key
  with an Android Keystore key. The file's password is never stored.
- The web view answers the server's certificate request on both platforms;
  WebSocket, service worker and API requests go through with it. The
  offline copy, watch requests and the check use it too.
- When the server stops letting the app in (certificate removed, expired
  or no longer trusted) while the page still comes from the service worker
  or the offline copy, a native check sends the app to the address screen,
  which offers to add another. A server that only asks and lets everyone
  in is left alone.
- Cloudflare-style 403 and nginx-style 400 refusals without a certificate
  lead to the same screen instead of staying up as an error page.
- Settings, Account: a Client certificate card (only with one stored)
  shows subject, issuer, server and expiry, with Replace and Remove.
- Wiki: Native-Apps "Client certificates (mTLS)" with Cloudflare, nginx,
  Caddy and Traefik setup and how to make a certificate for the app.
- The app icon on the unreachable screen is black on the light surface.
2026-11-09 12:03:31 +01:00
Maurice d3bdc6de62 fix(memories): keep the Synology repair's progress through the settings domain
The repair job wrote its marker rows through the AppSettings repository,
which the settings domain owns. It now registers both keys and goes through
SettingsService, as the import boundary check asks.
2026-11-09 12:03:27 +01:00
Maurice a9bfcd0fd5 fix(memories): log capture reconfirm failures through the Nest logger
Keeps the no-console and test-new-service ratchets at their baselines: the
reconfirm tests build the service through the suite's existing helper.
2026-11-09 11:32:27 +01:00
Maurice 12caa7fbde fix(synology): an unreachable library is reported, not asked a second time for the next id 2026-11-09 11:08:54 +01:00
Maurice 1cbd4acf01 feat(synology): photos attached before the fix get their own capture time and place once
Until #3019 a Synology photo's capture time and GPS came from the item after
it, which on many libraries is the next photo, and no one can edit a photo's
capture time to put it right. On the first start after this, every Synology
photo is asked again through its owner's library, held to the backfill's few
lookups per NAS, and what Synology confirms for that very photo replaces what
was stored; a photo it cannot confirm keeps its values. While a library could
not answer, the pass runs again on the next start, at most three times.
2026-11-09 10:59:43 +01:00
Maurice 687e477ca5 fix(synology): the asset lookup asks its two candidates without an await in a loop 2026-11-09 10:59:41 +01:00
Maurice 3a66f2a8da fix(native): the keyboard on the claim screen closes again
On "Almost there" nothing put the keyboard away: the web view keeps it open on
a tap beside the fields, and "Next" sent the half-filled form, whose check
focused a field again. A tap beside the fields or a drag of the screen now
closes it, as in an iOS app, on every first screen. "Next" walks from the
address to the email to the password, "Go" buys, and the keyboard goes as the
purchase starts.
2026-11-09 10:51:20 +01:00
Maurice 7f52e424e8 fix(native): a double tap on the app's first screens no longer zooms them on iOS 2026-11-09 10:49:57 +01:00
Maurice 2479e5661b fix(mobile): swiping days ticks as the swipe is decided, and a quick second swipe reaches the day after
The detent came only once the strip had settled, which on iOS is a good while
after the finger lifts, so it felt late. It now ticks as soon as another day
becomes the nearer page, and no second time as the snap lands.

The strip held the selected day and one on either side. A second swipe made
while the first still snapped ran into the end of the strip and sprang back,
so paging several days took a pause after each. It now holds two on either
side, and the second swipe carries on to the day after.
2026-11-09 10:49:55 +01:00
Maurice bc2fb1ce2d fix(synology): journey photos keep their own capture date and place, not the next photo's
getSynologyAssetInfo asked Browse.Item for the unit id plus one, a fixed
offset that holds on some libraries and on others names the next photo, whose
date and GPS were then stored for the attached one. It now asks for the unit
id, then for the one after, and takes only an item whose own thumbnail belongs
to the requested unit; when neither does, it answers not found instead of a
neighbour's metadata.

Closes #3019
2026-11-09 10:47:22 +01:00
Maurice ffa07b3d05 chore(i18n): two task keys are in use again, the unused baseline goes down 2026-11-09 10:25:47 +01:00
Maurice 306753d6a1 fix(native): placeholders pass the tests and the theme, the swipe actions use tokens, the task card stays simple 2026-11-09 10:02:02 +01:00
Maurice 80c04df4fc feat(native): swipe rows, long-press menus, placeholders while loading, fading pictures, iOS bounce and app icon quick actions
Packing items, tasks and expenses swipe sideways to their actions, as in
Mail or Reminders: done or open one way, delete and edit the other, the
first one run by a full swipe. They are second ways to what each row
already offers; an expense still asks before it goes.

A long press on a trip card lifts it and opens its menu with the card's own
actions. Plain confirmations in MConfirmSheet now also ask through the
system where the app can.

Lists, the atlas and a journey show grey placeholders in their shape while
loading instead of a spinner in an empty screen (MSkeleton); buttons keep
theirs. A picture still loading fades in once it is there; one from the
cache shows at once. In the iOS app the page bounces at its ends again,
except on screens with pull-to-refresh, whose pull it would race; Android
keeps its stretch off.

On iOS a long press on the app icon offers "New trip" and the trip coming
up by its name, set by the UI in its language (TrekShell.setQuickActions,
feature quickActions), and a tap opens it, also from a cold start.

The app's own CSS moves out of index.css into native/nativeApp.css, which
had outgrown its size budget.
2026-11-09 09:37:36 +01:00
Maurice c9a91bbdad fix(native): the native question reads no ref while rendering, and its tests type and lint clean 2026-11-09 09:16:58 +01:00
Maurice d06168d94a feat(native): the app answers a finger like a native app, not like a web page
After a tap, WebKit and Chrome keep :hover on what was tapped and send a
made-up mouseover, so buttons and rows stayed tinted until the next tap.
While a finger drives the app <html> carries data-touch-input: a PostCSS
step guards every :hover rule with it (no specificity change), and the
made-up mouseover and mouseout stop before any handler. A trackpad or mouse
clears the mark and gets hover back; a browser never sets it.

A control without a press of its own dims the moment a finger lands on it,
after a 60 ms wait so a scroll does not flash rows, and only the innermost
pressed control. A focus: ring no longer stays round a tapped button.

Fields get a fitting return key ("done", "go" in a form, "search" on the
search fields), and addresses, codes and numbers are no longer capitalised
or corrected. Ticking off packing items and tasks, a picker chip, a select
option, a picked date and dragging a plan row now give the haptics a native
list gives.

Confirmations ask through the system where the binary has the new
@capacitor/action-sheet and @capacitor/dialog plugins: a sheet with the
action in red when it destroys something, an alert otherwise, also for
"discard changes" in a sheet. An older app, a browser and a question with a
list under it keep the HTML dialog, as does a system that could not ask.

iOS no longer lifts a page preview on a long press on a link.
2026-11-09 08:54:51 +01:00
Maurice d9baaa8957 fix(native): a long press in the app selects no text and opens no callout
In the iOS and Android app a long press on a label, card or image started a text
selection, the magnifier or a "save image" menu, the clearest sign of a web page
in a frame. startNativeApp marks <html> with data-native-app, and index.css then
turns selection and the callout off for the UI. Inputs, textareas, selects and
editors stay selectable so typing, pasting and the caret work; text worth copying
opts in with .trek-selectable. A browser keeps its default.
2026-11-09 08:32:24 +01:00
Maurice fede671f3b fix(managed): bootstrap hands the managed routes to the boot gate, so the shared kernel imports no domain 2026-11-09 00:09:27 +00:00
Maurice b3113e66b9 feat(native): buying TREK Cloud in the app asks for the password of the new TREK and opens it signed in
The claim screen takes the password (TREK's policy, checked before anything is
bought) and sends it with the purchase; the control plane answers with a
one-time welcome code, which the app hands to setServer. iOS and Android open
the instance's welcome page with it once after the first page, which signs the
buyer in. Without the code (a restore, an older control plane) the app opens
the instance as before.
2026-11-08 23:43:15 +00:00
Maurice 286b0b09ec feat(managed): the managed attachment point declares the routes it serves without a session, and the boot gate allows exactly those
Empty in this repository, as the module itself. An install that replaces the
module adds its anonymous routes in the same file, and the gate still fails on a
route missing there or an entry that went stale.
2026-11-08 23:33:52 +00:00
Maurice c0b39abe18 style(i18n): the German native strings formatted as Prettier asks 2026-11-08 23:19:39 +00:00
Maurice 6e3a6386b8 fix(i18n): German setup screens speak of "dein TREK", as everywhere else, and the proxy note reads naturally 2026-11-08 22:24:29 +01:00
Maurice 5e7d778740 fix(assistant): the step after the ask for the answer may only write text, so Claude Sonnet 5.5 can no longer offer its follow-ups a second time instead (#3016) 2026-11-08 21:16:09 +01:00
Maurice 3be87343b8 fix: the gate texts formatted as the locales are, gate detection as a table of rules, and no non-null assertions in its tests 2026-11-08 20:58:49 +01:00
Maurice 517efabbca feat(native): the address screen recognises a sign-in proxy in front of TREK and signs in to it inside the app, so its session works as in a browser 2026-11-08 20:44:26 +01:00
Maurice 1866dca818 test(dashboard): the pair-change test stubs fetch with its own mock instead of a promise-returning mockImplementation 2026-11-08 20:10:22 +01:00
Maurice 1400b5d308 fix: the iOS tile trim handles its own failure instead of an empty catch, and the weight test's slow row saves without an async prop 2026-11-08 19:57:43 +01:00
Maurice b529cf4e57 fix: the checks the layout and weight fixes broke: a quote in a test name, state set inside effects, the misused-promise baseline, and tests that render the converter before settings load 2026-11-08 19:44:50 +01:00
Maurice 6cc3d235b1 fix(offline): on an iPhone or iPad the tile prefetch keeps to 2,048 tiles and a fuller cache is trimmed once a day, so the home screen app no longer starts on a black screen (#3008) 2026-11-08 19:33:13 +01:00
Maurice a5b1156612 fix(dashboard): the hero, the atlas cards and the collections widget hold their size while they load, and the currency widget shows only the saved pair and its own rates (#3009) 2026-11-08 19:26:18 +01:00
Maurice c6ffd48e26 fix(packing): the weight field keeps what is typed and saves it once, on blur or Enter, instead of a fragment per keystroke on a slow connection (#3005) 2026-11-08 19:19:04 +01:00
Maurice afb6eae4c8 fix(native): the handoff's passkey sign-in lives in native/browserSignIn, so the page hook reaches no API on its own 2026-11-08 18:33:00 +01:00
Maurice cd1f39377d test(native): the handoff page's fixture knows the passkey step, and the hook test keeps the real API client around its passkey mocks 2026-11-08 18:10:55 +01:00
Maurice 3a99e8576d fix(android): the sign-in browser opens from the UI thread, falls back to the plain browser where no Custom Tab comes up, and says so when none opens 2026-11-08 17:54:43 +01:00
Maurice 36c8c5bc48 fix(native): a passkey sign-in from the app asks for the passkey straight away in the browser and returns to the app, instead of showing the whole login a second time 2026-11-08 17:50:50 +01:00
Maurice 4ba7db90e6 fix(native): SSO in the system browser hands back to the app even when the provider finishes in another tab, and the handoff button's label shows 2026-11-08 17:43:28 +01:00
Maurice ab6cd7009e fix(login): the language picker's safe-area offset as classes, within the file's size baseline 2026-11-08 17:14:25 +01:00
Maurice b16ac683cf fix(native): Android's back gesture steps back through the first screens, the login's language picker clears the status bar, and a slow trip open shows its splash sooner 2026-11-08 16:51:28 +01:00
285 changed files with 8086 additions and 528 deletions
+2 -2
View File
@@ -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"))
+6
View File
@@ -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;
};
+68
View File
@@ -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)
}
}
+393
View File
@@ -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
}
}
+120 -2
View File
@@ -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
+1 -1
View File
@@ -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()
+4
View File
@@ -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"),
+1 -1
View File
@@ -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
View File
@@ -1,3 +1,4 @@
import { App } from '@capacitor/app';
import { Capacitor, CapacitorHttp } from '@capacitor/core';
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
import { SplashScreen } from '@capacitor/splash-screen';
@@ -5,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();
}
+44 -1
View File
@@ -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
View File
@@ -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",
+7 -5
View File
@@ -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()],
};
+49
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 375,
"@typescript-eslint/no-misused-promises": 845,
"@typescript-eslint/no-misused-promises": 843,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
+1
View File
@@ -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.",
+1 -1
View File
@@ -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,
+2
View File
@@ -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);
+43 -1
View File
@@ -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();
});
});
});
+18 -2
View File
@@ -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', () => {
+6 -6
View File
@@ -26,13 +26,13 @@ export function Skeleton({
// Trip card skeleton matching SpotlightCard layout
export function SpotlightSkeleton(): React.ReactElement {
// The dashboard hero's own box (.hero-trip: 520px on desktop, 340px on a
// phone, and its margin), so the hero takes the place it held while loading
// and nothing under it moves when it arrives (#3009).
return (
<div
className="relative rounded-3xl overflow-hidden mb-8 bg-surface-tertiary"
style={{ minHeight: 340 }}
>
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
<div className="hero-trip hero-trip-pending" aria-hidden>
<div className="trek-skeleton absolute inset-0" />
<div className="relative flex h-full flex-col justify-end p-6">
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
<Skeleton width={220} height={16} radius={4} />
</div>
+2 -2
View File
@@ -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);
+5
View File
@@ -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
+7 -1
View File
@@ -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>
);
}
+33 -10
View File
@@ -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', () => {
+13 -10
View File
@@ -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));
+14 -1
View File
@@ -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();
});
});
+226
View File
@@ -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;
+11 -7
View File
@@ -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();
},
};
}
+10
View File
@@ -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;
+3 -1
View File
@@ -18,7 +18,9 @@ import type { Location, NavigationType } from 'react-router';
*/
export type RouteTransitionKind = 'forward' | 'back' | 'tab';
const SETTLE_MS = 800;
// Short on purpose: while it waits the browser shows a frozen snapshot of the old
// page, so on a slow connection a longer wait reads as the app hanging.
const SETTLE_MS = 350;
let skipNext = false;
let current: ViewTransition | null = null;
@@ -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
+2 -3
View File
@@ -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