mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-11 06:41:02 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fbe10fe63 | |||
| 278871a31e | |||
| bdcef33a3e | |||
| 8e8cff8105 | |||
| 6b086eaed5 | |||
| 0ac06555cb | |||
| 1b87c62847 | |||
| 98eae61b90 | |||
| 19956f2742 | |||
| 7506eaa4aa | |||
| 57c9c9d5f2 | |||
| 0b3d2873c2 | |||
| 1454b58ea1 | |||
| 2ab4459bc3 | |||
| 6ebb92af6c | |||
| 3ab4927cb7 | |||
| 6a0ae482d7 | |||
| 20eeaeb61f | |||
| 5cbe4e2fa3 | |||
| b124e1bc9c | |||
| 08034c4402 | |||
| 0577a9ccb9 | |||
| 2f03554a60 | |||
| 28079197e7 | |||
| 0b46b89b62 | |||
| 05caf67bc4 | |||
| 6d3c400e4a | |||
| 9932288b04 | |||
| 56b13541bd | |||
| d568e20f02 | |||
| f03d59bc22 | |||
| 0833ca28c2 | |||
| 3ed5f21a48 | |||
| 2eb6b79bea | |||
| 8dd2e9716d | |||
| 1cdde6e9a4 | |||
| 034cfb539a | |||
| 5420077b98 |
@@ -175,7 +175,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -344,7 +344,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -403,7 +403,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
@@ -111,19 +111,21 @@ jobs:
|
||||
|
||||
ios:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: macos-15
|
||||
runs-on: xcode-27
|
||||
timeout-minutes: 60
|
||||
environment: store
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# App Store uploads must be built with Xcode 26; the image's default is older.
|
||||
- name: Select Xcode 26
|
||||
# The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
|
||||
# runs letterboxed on the inner display of an iPhone Duo.
|
||||
- name: Select Xcode 27.1
|
||||
run: |
|
||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
||||
test -n "$XCODE"
|
||||
XCODE=/Applications/Xcode_27.1.app
|
||||
test -d "$XCODE"
|
||||
sudo xcode-select -s "$XCODE"
|
||||
xcodebuild -version
|
||||
xcodebuild -showsdks | grep iphoneos27.1
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
@@ -90,18 +90,20 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
ios:
|
||||
runs-on: macos-15
|
||||
runs-on: xcode-27
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# App Store uploads must be built with Xcode 26; the image's default is older.
|
||||
- name: Select Xcode 26
|
||||
# The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
|
||||
# runs letterboxed on the inner display of an iPhone Duo.
|
||||
- name: Select Xcode 27.1
|
||||
run: |
|
||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
||||
test -n "$XCODE"
|
||||
XCODE=/Applications/Xcode_27.1.app
|
||||
test -d "$XCODE"
|
||||
sudo xcode-select -s "$XCODE"
|
||||
xcodebuild -version
|
||||
xcodebuild -showsdks | grep iphoneos27.1
|
||||
|
||||
- name: Check the property lists
|
||||
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
load: true
|
||||
tags: trek:scan
|
||||
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
path: plugin-sdk/coverage/
|
||||
|
||||
- name: Scan
|
||||
uses: SonarSource/sonarqube-scan-action@4d1dc0c91ee400dcf8a6dddf2f705ac36542e304 # v8.4.0
|
||||
uses: SonarSource/sonarqube-scan-action@e805d875275f728fac1885b413934be6d7c63f65 # v8.4.1
|
||||
env:
|
||||
# SonarQube Cloud is the scan action's default host, so no
|
||||
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.1.2
|
||||
version: 5.1.3
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.1.2"
|
||||
appVersion: "5.1.3"
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.os.Bundle;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.KeyboardShortcutGroup;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import com.liketrek.trek.screen.TrekKeyCommands;
|
||||
import com.liketrek.trek.tiles.TrekTileStore;
|
||||
@@ -26,6 +34,7 @@ public class MainActivity extends BridgeActivity {
|
||||
// runs and on its own origin. BridgeActivity reads `config` while it
|
||||
// builds the bridge inside super.onCreate().
|
||||
boolean restarting = TrekServerStore.consumeRestart(this);
|
||||
TrekServerStore.recheckAfterUpdate(this);
|
||||
String server = TrekServerStore.getUrl(this);
|
||||
boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this);
|
||||
config = TrekServerStore.configFor(this, onServer ? server : null, restarting);
|
||||
@@ -36,7 +45,11 @@ 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, TrekServerStore.getSignInHosts(this)));
|
||||
if (onServer) {
|
||||
bridge.setWebViewClient(
|
||||
new TrekWebViewClient(bridge, this::showAddressScreen, this::showSignInBar, TrekServerStore.getSignInHosts(this))
|
||||
);
|
||||
}
|
||||
TrekSharedFiles.receive(this, getIntent());
|
||||
// Android 15's widget picker draws the widgets live from a sample trip.
|
||||
TrekWidgetUpdater.publishPreviews(this);
|
||||
@@ -68,6 +81,93 @@ public class MainActivity extends BridgeActivity {
|
||||
|
||||
private boolean leavingServer;
|
||||
|
||||
private View signInBar;
|
||||
/** Whether the page is a proxy's sign-in page, and whether the keyboard is up over it. */
|
||||
private boolean onSignInPage;
|
||||
private boolean keyboardUp;
|
||||
|
||||
/**
|
||||
* Over a proxy's sign-in page (Pangolin, authentik, ...): the server's UI is
|
||||
* not there, so nothing else would lead back to the address screen, and a
|
||||
* passkey cannot work in a web view for a domain the app is not tied to.
|
||||
* The bar says so and offers to change the server.
|
||||
*/
|
||||
private void showSignInBar(boolean show) {
|
||||
runOnUiThread(() -> {
|
||||
onSignInPage = show;
|
||||
if (show && signInBar == null) signInBar = buildSignInBar();
|
||||
updateSignInBar();
|
||||
});
|
||||
}
|
||||
|
||||
/** Out of the way while the keyboard is up: the form needs the room, and the bar is there again once it closes. */
|
||||
private void updateSignInBar() {
|
||||
if (signInBar != null) signInBar.setVisibility(onSignInPage && !keyboardUp ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private View buildSignInBar() {
|
||||
float dp = getResources().getDisplayMetrics().density;
|
||||
LinearLayout bar = new LinearLayout(this);
|
||||
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
bar.setGravity(Gravity.CENTER_VERTICAL);
|
||||
bar.setPadding(Math.round(14 * dp), Math.round(10 * dp), Math.round(8 * dp), Math.round(10 * dp));
|
||||
GradientDrawable background = new GradientDrawable();
|
||||
background.setColor(0xF2111827);
|
||||
background.setCornerRadius(16 * dp);
|
||||
bar.setBackground(background);
|
||||
bar.setElevation(6 * dp);
|
||||
|
||||
TextView hint = new TextView(this);
|
||||
hint.setText(TrekServerStore.signInHintLabel(this));
|
||||
hint.setTextColor(0xFFE5E7EB);
|
||||
hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12.5f);
|
||||
bar.addView(hint, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
|
||||
|
||||
Button change = new Button(this, null, android.R.attr.borderlessButtonStyle);
|
||||
change.setText(TrekServerStore.changeServerLabel(this));
|
||||
change.setAllCaps(false);
|
||||
change.setTextColor(0xFFFFFFFF);
|
||||
change.setOnClickListener((v) -> {
|
||||
TrekServerStore.setUrl(this, null);
|
||||
TrekServerStore.markRestart(this);
|
||||
recreate();
|
||||
});
|
||||
bar.addView(change, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
int margin = Math.round(12 * dp);
|
||||
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM
|
||||
);
|
||||
params.setMargins(margin, 0, margin, margin);
|
||||
FrameLayout root = findViewById(android.R.id.content);
|
||||
root.addView(bar, params);
|
||||
// Above the gesture bar, and above the keyboard while it is up.
|
||||
bar.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||
// Read off the window's own insets: Capacitor hands the views below the
|
||||
// decor a copy with the system bars zeroed, and before Android 11 that
|
||||
// takes the keyboard with it.
|
||||
androidx.core.view.WindowInsetsCompat window = androidx.core.view.ViewCompat.getRootWindowInsets(getWindow().getDecorView());
|
||||
int bottom = window != null
|
||||
? window.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom
|
||||
: insets.getStableInsetBottom();
|
||||
boolean imeVisible = window != null && window.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime());
|
||||
if (imeVisible != keyboardUp) {
|
||||
keyboardUp = imeVisible;
|
||||
updateSignInBar();
|
||||
}
|
||||
FrameLayout.LayoutParams p = (FrameLayout.LayoutParams) v.getLayoutParams();
|
||||
if (p.bottomMargin != margin + bottom) {
|
||||
p.bottomMargin = margin + bottom;
|
||||
v.setLayoutParams(p);
|
||||
}
|
||||
return insets;
|
||||
});
|
||||
bar.requestApplyInsets();
|
||||
return bar;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server did not load: back to the address screen, which offers retry
|
||||
* and change. A fresh task rather than recreate(), because this usually
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,18 @@ final class TrekServerStore {
|
||||
|
||||
private static final String PREFS = "trek_shell";
|
||||
private static final String KEY_URL = "server_url";
|
||||
private static final String KEY_PREVIOUS_URL = "previous_server_url";
|
||||
private static final String KEY_SERVER_CHECK = "server_check";
|
||||
private static final String KEY_LABEL_SIGN_IN_HINT = "label_sign_in_hint";
|
||||
private static final String KEY_LABEL_CHANGE_SERVER = "label_change_server";
|
||||
|
||||
/**
|
||||
* The server API this build needs (MIN_NATIVE_API in serverAddress.ts). A
|
||||
* server remembered under a lower value is checked once more: the first
|
||||
* launch after an update that raised it puts the address screen back up,
|
||||
* filled in with that server (getPreviousUrl). Raise both together.
|
||||
*/
|
||||
private static final int SERVER_CHECK = 2;
|
||||
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";
|
||||
@@ -56,6 +68,44 @@ final class TrekServerStore {
|
||||
return prefs(context).getString(KEY_URL, null);
|
||||
}
|
||||
|
||||
/** Runs before the bridge is built, so a server picked under an older minimum is not loaded unchecked. */
|
||||
static void recheckAfterUpdate(Context context) {
|
||||
SharedPreferences prefs = prefs(context);
|
||||
if (prefs.getInt(KEY_SERVER_CHECK, 0) >= SERVER_CHECK) return;
|
||||
String server = getUrl(context);
|
||||
SharedPreferences.Editor editor = prefs.edit().putInt(KEY_SERVER_CHECK, SERVER_CHECK);
|
||||
if (server != null) editor.remove(KEY_URL).remove(KEY_SIGN_IN_HOSTS).putString(KEY_PREVIOUS_URL, server);
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* The words of the bar over a proxy's sign-in page, in the user's language,
|
||||
* as the address screen passed them (setServer's `labels`): the server's UI
|
||||
* is not there to say them. Null keeps the English fallback.
|
||||
*/
|
||||
static void setLabels(Context context, String signInHint, String changeServer) {
|
||||
SharedPreferences.Editor editor = prefs(context).edit();
|
||||
if (signInHint != null) editor.putString(KEY_LABEL_SIGN_IN_HINT, signInHint);
|
||||
if (changeServer != null) editor.putString(KEY_LABEL_CHANGE_SERVER, changeServer);
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
static String signInHintLabel(Context context) {
|
||||
return prefs(context).getString(
|
||||
KEY_LABEL_SIGN_IN_HINT,
|
||||
"Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
|
||||
);
|
||||
}
|
||||
|
||||
static String changeServerLabel(Context context) {
|
||||
return prefs(context).getString(KEY_LABEL_CHANGE_SERVER, "Change server");
|
||||
}
|
||||
|
||||
/** The server an update asked to check again, until a server is set. */
|
||||
static String getPreviousUrl(Context context) {
|
||||
return prefs(context).getString(KEY_PREVIOUS_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);
|
||||
@@ -66,7 +116,7 @@ final class TrekServerStore {
|
||||
if (url == null) {
|
||||
editor.remove(KEY_URL);
|
||||
} else {
|
||||
editor.putString(KEY_URL, url);
|
||||
editor.putString(KEY_URL, url).remove(KEY_PREVIOUS_URL);
|
||||
}
|
||||
if (signInHosts == null || signInHosts.isEmpty()) {
|
||||
editor.remove(KEY_SIGN_IN_HOSTS);
|
||||
@@ -89,6 +139,13 @@ final class TrekServerStore {
|
||||
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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
@@ -70,6 +71,7 @@ public class TrekShellPlugin extends Plugin {
|
||||
"keyCommands",
|
||||
"windowLayout",
|
||||
"clientCertificate",
|
||||
"filePreview",
|
||||
};
|
||||
|
||||
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
|
||||
@@ -267,11 +269,51 @@ public class TrekShellPlugin extends Plugin {
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file the UI wrote to the cache in the app that views it, a PDF in
|
||||
* the system's PDF viewer, instead of the share sheet. "unsupported" where no
|
||||
* app on the device views the type; the UI then hands it to the share sheet.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void previewFile(PluginCall call) {
|
||||
String path = call.getString("path");
|
||||
Uri parsed = path == null ? null : Uri.parse(path);
|
||||
java.io.File file = parsed != null && "file".equals(parsed.getScheme()) && parsed.getPath() != null ? new java.io.File(parsed.getPath()) : null;
|
||||
java.io.File cache = getContext().getCacheDir();
|
||||
boolean inCache;
|
||||
try {
|
||||
inCache = file != null && file.getCanonicalPath().startsWith(cache.getCanonicalPath() + java.io.File.separator);
|
||||
} catch (java.io.IOException e) {
|
||||
inCache = false;
|
||||
}
|
||||
if (!inCache || !file.isFile()) {
|
||||
call.reject("a file in the app's cache is required", "INVALID");
|
||||
return;
|
||||
}
|
||||
String type = call.getString("mimeType", "application/octet-stream");
|
||||
Uri content = androidx.core.content.FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file);
|
||||
Intent view = new Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(content, type)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
getActivity().runOnUiThread(() -> {
|
||||
JSObject result = new JSObject();
|
||||
try {
|
||||
getActivity().startActivity(view);
|
||||
result.put("result", "shown");
|
||||
} catch (ActivityNotFoundException e) {
|
||||
result.put("result", "unsupported");
|
||||
}
|
||||
call.resolve(result);
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getServer(PluginCall call) {
|
||||
String url = TrekServerStore.getUrl(getContext());
|
||||
String previous = TrekServerStore.getPreviousUrl(getContext());
|
||||
JSObject result = new JSObject();
|
||||
result.put("url", url == null ? JSONObject.NULL : url);
|
||||
result.put("previousUrl", previous == null ? JSONObject.NULL : previous);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@@ -284,6 +326,10 @@ public class TrekShellPlugin extends Plugin {
|
||||
return;
|
||||
}
|
||||
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
|
||||
JSObject labels = call.getObject("labels");
|
||||
if (labels != null) {
|
||||
TrekServerStore.setLabels(getContext(), labels.getString("signInHint"), labels.getString("changeServer"));
|
||||
}
|
||||
// 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);
|
||||
|
||||
@@ -9,8 +9,10 @@ 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;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Used while the WebView shows the user's server. When that server cannot be
|
||||
@@ -23,12 +25,17 @@ import java.util.Set;
|
||||
class TrekWebViewClient extends BridgeWebViewClient {
|
||||
|
||||
private final Runnable onUnreachable;
|
||||
/** Told whether the page now shown is a proxy's sign-in page, for the bar with the way out. */
|
||||
private final Consumer<Boolean> onSignInPage;
|
||||
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, Set<String> signInHosts) {
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Consumer<Boolean> onSignInPage, Set<String> signInHosts) {
|
||||
super(bridge);
|
||||
this.onUnreachable = onUnreachable;
|
||||
this.signInHosts = signInHosts;
|
||||
this.onSignInPage = onSignInPage;
|
||||
this.signInHosts = new HashSet<>(signInHosts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,13 +50,33 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
@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);
|
||||
}
|
||||
|
||||
/** Every page the main frame shows, pushState included: is it a proxy's sign-in page? */
|
||||
@Override
|
||||
public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) {
|
||||
super.doUpdateVisitedHistory(view, url, isReload);
|
||||
onSignInPage.accept(url != null && isSignInHost(Uri.parse(url)));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -10,6 +10,7 @@ import WebKit
|
||||
class TrekBridgeViewController: CAPBridgeViewController {
|
||||
override func instanceDescriptor() -> InstanceDescriptor {
|
||||
let descriptor = super.instanceDescriptor()
|
||||
TrekServerStore.recheckAfterUpdate()
|
||||
if let server = TrekServerStore.url {
|
||||
// The trailing slash matters: Capacitor treats every URL that starts
|
||||
// with this string as the app's own, and without it that would take
|
||||
@@ -44,6 +45,7 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
webView.navigationDelegate = proxy
|
||||
}
|
||||
guardBridgeMessages()
|
||||
watchSignInPages()
|
||||
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
|
||||
@@ -55,15 +57,164 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
webView?.allowsLinkPreview = false
|
||||
}
|
||||
|
||||
// MARK: - A proxy's sign-in page
|
||||
|
||||
/// Over a proxy's sign-in page (Pangolin, authentik, ...) the server's UI is
|
||||
/// not there, so nothing else leads back to the address screen, and a passkey
|
||||
/// cannot work in a web view for a domain the app is not tied to. A bar says
|
||||
/// so and offers to change the server; the keyboard gets room on that page,
|
||||
/// which TREK's own script makes everywhere else (native/keepFieldInView.ts).
|
||||
private var signInBar: UIView?
|
||||
private var urlObservation: NSKeyValueObservation?
|
||||
private var insetObservation: NSKeyValueObservation?
|
||||
private var onSignInPage = false
|
||||
private var keyboardUp = false
|
||||
/// How far the keyboard reaches into the view while it is up.
|
||||
private var keyboardRoom: CGFloat = 0
|
||||
/// Whether the bottom inset is this controller's, so leaving the page puts it back.
|
||||
private var ownsInset = false
|
||||
private var bodyReset: DispatchWorkItem?
|
||||
|
||||
private func watchSignInPages() {
|
||||
urlObservation = webView?.observe(\.url, options: [.initial, .new]) { [weak self] webView, _ in
|
||||
let signIn = TrekServerStore.isSignInHost(webView.url)
|
||||
DispatchQueue.main.async { self?.signInPageChanged(signIn) }
|
||||
}
|
||||
// The keyboard plugin zeroes the scroll inset on every frame change it
|
||||
// makes, the last one a while after the keyboard is up. On a sign-in
|
||||
// page the inset wanted here is put back each time.
|
||||
insetObservation = webView?.scrollView.observe(\.contentInset, options: [.new]) { [weak self] _, _ in
|
||||
DispatchQueue.main.async { self?.applyInset() }
|
||||
}
|
||||
let center = NotificationCenter.default
|
||||
center.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
center.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
}
|
||||
|
||||
private func signInPageChanged(_ signIn: Bool) {
|
||||
onSignInPage = signIn
|
||||
if signIn && signInBar == nil { signInBar = makeSignInBar() }
|
||||
updateSignInBar()
|
||||
}
|
||||
|
||||
/// Out of the way while the keyboard is up: the form needs the room.
|
||||
private func updateSignInBar() {
|
||||
signInBar?.isHidden = !(onSignInPage && !keyboardUp)
|
||||
applyInset()
|
||||
}
|
||||
|
||||
/// On a sign-in page the page can scroll past what covers its end: the
|
||||
/// keyboard while it is up, the bar otherwise. TREK's own pages are left alone.
|
||||
private func applyInset() {
|
||||
guard let scrollView = webView?.scrollView else { return }
|
||||
let wanted: CGFloat
|
||||
if onSignInPage {
|
||||
if keyboardUp {
|
||||
wanted = keyboardRoom
|
||||
} else if let bar = signInBar {
|
||||
view.layoutIfNeeded()
|
||||
wanted = max(0, view.bounds.height - bar.frame.minY)
|
||||
} else {
|
||||
wanted = 0
|
||||
}
|
||||
ownsInset = true
|
||||
} else if ownsInset {
|
||||
wanted = 0
|
||||
ownsInset = false
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if scrollView.contentInset.bottom != wanted { scrollView.contentInset.bottom = wanted }
|
||||
if scrollView.verticalScrollIndicatorInsets.bottom != wanted { scrollView.verticalScrollIndicatorInsets.bottom = wanted }
|
||||
}
|
||||
|
||||
/// The keyboard plugin also sizes the body to the area above the keyboard,
|
||||
/// after the keyboard's animation and a further 0.2 s. On a sign-in page the
|
||||
/// body gets its height back once that has run, so the button under the
|
||||
/// keyboard can be scrolled into view through the inset above.
|
||||
@objc private func keyboardWillShow(_ note: Notification) {
|
||||
keyboardUp = true
|
||||
if let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
|
||||
keyboardRoom = max(0, view.bounds.height - view.convert(frame, from: nil).minY)
|
||||
}
|
||||
bodyReset?.cancel()
|
||||
if onSignInPage {
|
||||
let duration = note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25
|
||||
let reset = DispatchWorkItem { [weak self] in
|
||||
guard let self, self.onSignInPage, self.keyboardUp, let webView = self.webView else { return }
|
||||
webView.evaluateJavaScript("document.body.style.height = ''", completionHandler: nil)
|
||||
self.applyInset()
|
||||
}
|
||||
bodyReset = reset
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.35, execute: reset)
|
||||
}
|
||||
updateSignInBar()
|
||||
}
|
||||
|
||||
@objc private func keyboardWillHide() {
|
||||
keyboardUp = false
|
||||
bodyReset?.cancel()
|
||||
updateSignInBar()
|
||||
}
|
||||
|
||||
private func makeSignInBar() -> UIView {
|
||||
let labels = TrekServerStore.labels
|
||||
let bar = UIView()
|
||||
bar.backgroundColor = UIColor(red: 17 / 255, green: 24 / 255, blue: 39 / 255, alpha: 0.95)
|
||||
bar.layer.cornerRadius = 16
|
||||
bar.layer.cornerCurve = .continuous
|
||||
bar.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let hint = UILabel()
|
||||
hint.text = labels["signInHint"]
|
||||
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
|
||||
hint.textColor = UIColor(white: 0.9, alpha: 1)
|
||||
hint.font = .preferredFont(forTextStyle: .footnote)
|
||||
hint.adjustsFontForContentSizeCategory = true
|
||||
hint.numberOfLines = 0
|
||||
hint.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let change = UIButton(type: .system)
|
||||
change.setTitle(labels["changeServer"] ?? "Change server", for: .normal)
|
||||
change.setTitleColor(.white, for: .normal)
|
||||
change.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
change.addTarget(self, action: #selector(changeServerFromSignIn), for: .touchUpInside)
|
||||
change.translatesAutoresizingMaskIntoConstraints = false
|
||||
change.setContentHuggingPriority(.required, for: .horizontal)
|
||||
change.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
|
||||
bar.addSubview(hint)
|
||||
bar.addSubview(change)
|
||||
view.addSubview(bar)
|
||||
NSLayoutConstraint.activate([
|
||||
bar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
|
||||
bar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
|
||||
bar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
|
||||
hint.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 14),
|
||||
hint.topAnchor.constraint(equalTo: bar.topAnchor, constant: 10),
|
||||
hint.bottomAnchor.constraint(equalTo: bar.bottomAnchor, constant: -10),
|
||||
change.leadingAnchor.constraint(equalTo: hint.trailingAnchor, constant: 8),
|
||||
change.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -12),
|
||||
change.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
|
||||
])
|
||||
return bar
|
||||
}
|
||||
|
||||
/// What resetServer does from the UI: forget the server, back to the address screen.
|
||||
@objc private func changeServerFromSignIn() {
|
||||
TrekServerStore.url = nil
|
||||
view.window?.rootViewController = TrekBridgeViewController()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// reach the plugins. Always in place: a sign-in host can also be learned
|
||||
/// while the app runs (TrekSignInRedirect).
|
||||
private func guardBridgeMessages() {
|
||||
guard !TrekServerStore.signInHosts.isEmpty,
|
||||
let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
|
||||
guard let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
|
||||
let controller = webView?.configuration.userContentController else { return }
|
||||
let guarded = TrekBridgeMessageGuard(inner: handler)
|
||||
controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name)
|
||||
@@ -157,13 +308,54 @@ enum TrekKeyCommands {
|
||||
enum TrekServerStore {
|
||||
private static let key = "TrekServerURL"
|
||||
|
||||
private static let previousKey = "TrekPreviousServerURL"
|
||||
|
||||
private static let checkedKey = "TrekServerCheck"
|
||||
|
||||
private static let labelsKey = "TrekShellLabels"
|
||||
|
||||
/// The words of the bar over a proxy's sign-in page, in the user's language,
|
||||
/// as the address screen passed them (setServer's `labels`): the server's UI
|
||||
/// is not there to say them. Empty keeps the English fallback.
|
||||
static var labels: [String: String] {
|
||||
get { UserDefaults.standard.dictionary(forKey: labelsKey) as? [String: String] ?? [:] }
|
||||
set { UserDefaults.standard.set(newValue, forKey: labelsKey) }
|
||||
}
|
||||
|
||||
/// The server API this build needs (MIN_NATIVE_API in serverAddress.ts).
|
||||
/// A server remembered under a lower value is checked once more: the first
|
||||
/// launch after an update that raised it puts the address screen back up,
|
||||
/// filled in with that server (previousURL). Raise both together.
|
||||
private static let serverCheck = 2
|
||||
|
||||
/// Runs before the first bridge is built, so a server picked under an older
|
||||
/// minimum is not loaded unchecked.
|
||||
static func recheckAfterUpdate() {
|
||||
let defaults = UserDefaults.standard
|
||||
guard defaults.integer(forKey: checkedKey) < serverCheck else { return }
|
||||
defaults.set(serverCheck, forKey: checkedKey)
|
||||
guard let server = url else { return }
|
||||
url = nil
|
||||
defaults.set(server, forKey: previousKey)
|
||||
}
|
||||
|
||||
/// The server an update asked to check again, until a server is set.
|
||||
static var previousURL: String? {
|
||||
UserDefaults.standard.string(forKey: previousKey)
|
||||
}
|
||||
|
||||
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) }
|
||||
if let newValue {
|
||||
UserDefaults.standard.set(newValue, forKey: key)
|
||||
UserDefaults.standard.removeObject(forKey: previousKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
UserDefaults.standard.removeObject(forKey: signInHostsKey)
|
||||
try? FileManager.default.removeItem(at: offlineCopyURL)
|
||||
}
|
||||
@@ -185,6 +377,13 @@ enum TrekServerStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
@@ -428,12 +627,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
|
||||
@@ -462,6 +693,18 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
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)
|
||||
@@ -474,6 +717,7 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
/// 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))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ActivityKit
|
||||
#endif
|
||||
import LocalAuthentication
|
||||
import PassKit
|
||||
import QuickLook
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
@@ -55,6 +56,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "previewFile", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// What this binary offers the server's UI, raised with every native feature
|
||||
@@ -66,7 +68,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", "quickActions", "clientCertificate"]
|
||||
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate", "filePreview"]
|
||||
if canAddWalletPasses { names.append("wallet") }
|
||||
if TrekWatchLink.isAvailable { names.append("watch") }
|
||||
return names
|
||||
@@ -346,7 +348,10 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
private var authSession: ASWebAuthenticationSession?
|
||||
|
||||
@objc func getServer(_ call: CAPPluginCall) {
|
||||
call.resolve(["url": TrekServerStore.url ?? NSNull()])
|
||||
call.resolve([
|
||||
"url": TrekServerStore.url ?? NSNull(),
|
||||
"previousUrl": TrekServerStore.previousURL ?? NSNull(),
|
||||
])
|
||||
}
|
||||
|
||||
@objc func setServer(_ call: CAPPluginCall) {
|
||||
@@ -355,6 +360,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
return
|
||||
}
|
||||
TrekServerStore.url = raw
|
||||
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
|
||||
if !labels.isEmpty { TrekServerStore.labels = labels }
|
||||
// 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() })
|
||||
@@ -555,6 +562,41 @@ extension TrekShellPlugin {
|
||||
|
||||
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
|
||||
/// When-In-Use permission asked the first time.
|
||||
// MARK: - Quick Look
|
||||
|
||||
extension TrekShellPlugin {
|
||||
/// Kept while Quick Look shows a file: the controller holds its data source weakly.
|
||||
/// One preview at a time, so a static: an extension cannot store per instance.
|
||||
private static var previewSource: TrekFilePreviewSource?
|
||||
|
||||
/// Shows a file the UI wrote to the cache in Quick Look: a PDF to page through,
|
||||
/// an image, a document, with the system's own share button. "unsupported" for
|
||||
/// a file Quick Look cannot show, which the UI then hands to the share sheet.
|
||||
@objc func previewFile(_ call: CAPPluginCall) {
|
||||
guard let path = call.getString("path"), let url = URL(string: path), url.isFileURL,
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
call.reject("a file in the app's cache is required", "INVALID")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let presenter = self.bridge?.viewController else {
|
||||
call.reject("nothing to show the preview from", "UNAVAILABLE")
|
||||
return
|
||||
}
|
||||
guard QLPreviewController.canPreview(url as QLPreviewItem) else {
|
||||
call.resolve(["result": "unsupported"])
|
||||
return
|
||||
}
|
||||
let source = TrekFilePreviewSource(url: url, title: call.getString("title"))
|
||||
TrekShellPlugin.previewSource = source
|
||||
let preview = QLPreviewController()
|
||||
preview.dataSource = source
|
||||
presenter.present(preview, animated: true)
|
||||
call.resolve(["result": "shown"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Apple Wallet
|
||||
|
||||
extension TrekShellPlugin {
|
||||
@@ -957,3 +999,26 @@ enum TrekSharedKeychain {
|
||||
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
}
|
||||
|
||||
/// The one file Quick Look shows, under the name the user knows it by.
|
||||
final class TrekFilePreviewSource: NSObject, QLPreviewControllerDataSource {
|
||||
private let item: Item
|
||||
|
||||
init(url: URL, title: String?) {
|
||||
item = Item(url: url, title: title)
|
||||
}
|
||||
|
||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
|
||||
|
||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { item }
|
||||
|
||||
final class Item: NSObject, QLPreviewItem {
|
||||
let previewItemURL: URL?
|
||||
let previewItemTitle: String?
|
||||
|
||||
init(url: URL, title: String?) {
|
||||
previewItemURL = url
|
||||
previewItemTitle = title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +275,12 @@ function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
}
|
||||
|
||||
// ── Connect (address) ───────────────────────────────────────────────────────
|
||||
function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
/**
|
||||
* `recheck`: an app update needs a newer server than the one it remembered, so
|
||||
* the address screen comes back once with it filled in, says why and checks it
|
||||
* right away. A server that is new enough loads again without another tap.
|
||||
*/
|
||||
function showConnect(prefill = '', opts: { back?: boolean; recheck?: boolean } = {}): void {
|
||||
const input = el('input', {
|
||||
type: 'url',
|
||||
placeholder: t('native.connect.placeholder'),
|
||||
@@ -344,14 +349,15 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
[
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.connect.title')),
|
||||
el('p', { className: 'sub' }, t('native.connect.hint')),
|
||||
el('p', { className: 'sub' }, t(opts.recheck ? 'native.connect.recheckHint' : 'native.connect.hint')),
|
||||
],
|
||||
[form, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
requestAnimationFrame(() => input.focus());
|
||||
if (opts.recheck && prefill) form.requestSubmit();
|
||||
else requestAnimationFrame(() => input.focus());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1200,6 +1206,7 @@ async function connected(url: string, signInHosts: string[] = [], welcome?: stri
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
await TrekShell.setServer({
|
||||
url,
|
||||
labels: { signInHint: t('native.signInBar.hint'), changeServer: t('native.changeServer') },
|
||||
...(signInHosts.length ? { signInHosts } : {}),
|
||||
...(welcome ? { welcome } : {}),
|
||||
});
|
||||
@@ -1322,8 +1329,9 @@ async function start(): Promise<void> {
|
||||
followBackGesture();
|
||||
letKeyboardClose();
|
||||
let url: string | null;
|
||||
let previousUrl: string | null | undefined;
|
||||
try {
|
||||
({ url } = await TrekShell.getServer());
|
||||
({ url, previousUrl } = await TrekShell.getServer());
|
||||
} catch {
|
||||
// No native shell under us (a web preview, or a simulator without the
|
||||
// plugin): there is no remembered server, so start at the welcome screen
|
||||
@@ -1332,7 +1340,9 @@ async function start(): Promise<void> {
|
||||
await SplashScreen.hide().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!url) {
|
||||
if (!url && previousUrl) {
|
||||
showConnect(previousUrl, { recheck: true });
|
||||
} else if (!url) {
|
||||
showWelcome();
|
||||
} else {
|
||||
// Only reached when loading the server failed. A proxy that wanted a client
|
||||
|
||||
+22
-22
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.1.2",
|
||||
"version": "5.1.3",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -52,15 +52,15 @@
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@assistant-ui/react": "0.15.32",
|
||||
"@capacitor/action-sheet": "^8.1.1",
|
||||
"@capacitor/android": "^8.5.0",
|
||||
"@capacitor/android": "^8.6.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",
|
||||
"@capacitor/ios": "^8.6.0",
|
||||
"@capacitor/keyboard": "^8.0.5",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/splash-screen": "^8.0.2",
|
||||
@@ -105,19 +105,19 @@
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@trek/shared": "*",
|
||||
"axios": "^1.20.0",
|
||||
"dexie": "^4.4.6",
|
||||
"axios": "^1.21.0",
|
||||
"dexie": "^4.4.7",
|
||||
"drag-drop-touch": "^1.3.1",
|
||||
"heic-to": "^1.5.2",
|
||||
"heic-to": "^1.6.7",
|
||||
"iso-3166-2": "^1.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"modern-screenshot": "4.7.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"plyr": "^3.8.5",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-leaflet-cluster": "^4.1.3",
|
||||
@@ -131,41 +131,41 @@
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.0",
|
||||
"@capacitor/cli": "^8.6.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@jscpd/core": "^4.2.5",
|
||||
"@jscpd/tokenizer": "^4.2.6",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@playwright/test": "^1.65.0",
|
||||
"@testing-library/jest-dom": "^6.10.0",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@testing-library/user-event": "^14.6.8",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^25.9.8",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitejs/plugin-react": "^6.2.1",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"autoprefixer": "^10.6.2",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-flat-gitignore": "^2.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"fake-indexeddb": "^6.2.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"msw": "^2.15.0",
|
||||
"postcss": "^8.5.28",
|
||||
"prettier": "^3.9.8",
|
||||
"prettier": "^3.9.10",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"rollup-plugin-visualizer": "^7.1.1",
|
||||
"rollup-plugin-visualizer": "^7.1.2",
|
||||
"sharp": "^0.35.5",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
"vite": "8.3.6",
|
||||
"vite-plugin-pwa": "^2.0.0",
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// The page /api/auth/native/start answers with runs this (see NativeAuthController.start).
|
||||
// A browser that ran an older TREK keeps that version's app in its service worker, and
|
||||
// the worker would answer the handoff page with an app that has no such page. So the
|
||||
// worker goes first, and the handoff page loads from the server.
|
||||
(function () {
|
||||
var script = document.currentScript;
|
||||
var target = script && script.getAttribute('data-target');
|
||||
if (!target || target.indexOf('/native-handoff?') !== 0) return;
|
||||
var gone = false;
|
||||
var go = function () {
|
||||
if (gone) return;
|
||||
gone = true;
|
||||
location.replace(target);
|
||||
};
|
||||
// Until the sign-in is handed back to the app, no worker is registered and no
|
||||
// new version reloads the page (src/native/browserSignInFlag.ts): a tab still
|
||||
// open on the older TREK keeps its worker alive, and either would bring it back.
|
||||
try {
|
||||
localStorage.setItem('trek_native_signin', String(Date.now()));
|
||||
} catch (e) {
|
||||
/* site data blocked: the worker is still taken off below */
|
||||
}
|
||||
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.getRegistrations) {
|
||||
go();
|
||||
return;
|
||||
}
|
||||
// Never stuck here: whatever the browser does with the workers, the page moves on.
|
||||
var timer = setTimeout(go, 3000);
|
||||
navigator.serviceWorker
|
||||
.getRegistrations()
|
||||
.then(function (registrations) {
|
||||
return Promise.all(
|
||||
registrations.map(function (registration) {
|
||||
return registration.unregister();
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(function () {})
|
||||
.then(function () {
|
||||
clearTimeout(timer);
|
||||
go();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,18 @@
|
||||
// Registers the service worker, as vite-plugin-pwa's own registerSW.js does; this
|
||||
// file in public/ takes its place. Not while the app's sign-in runs in this browser
|
||||
// (src/native/browserSignInFlag.ts): a tab still open on an older TREK keeps that
|
||||
// version's worker alive, and registering now would bring it back to answer the
|
||||
// sign-in's next page with an app that has no handoff page.
|
||||
(function () {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
try {
|
||||
var started = Number(localStorage.getItem('trek_native_signin'));
|
||||
var age = Date.now() - started;
|
||||
if (started > 0 && age >= 0 && age < 15 * 60 * 1000) return;
|
||||
} catch (e) {
|
||||
/* site data blocked: register as usual */
|
||||
}
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
});
|
||||
})();
|
||||
@@ -18,7 +18,7 @@
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1506,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4028,
|
||||
"src/pages/LoginPage.tsx": 1347,
|
||||
"src/pages/LoginPage.tsx": 1294,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/atlas/useAtlas.ts": 1207,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
"pages/JourneyPublicPage.tsx": 113,
|
||||
"pages/LoginPage.tsx": 58,
|
||||
"pages/LoginPage.tsx": 54,
|
||||
"pages/OAuthAuthorizePage.tsx": 33,
|
||||
"pages/PluginPage.tsx": 1,
|
||||
"pages/ResetPasswordPage.tsx": 23,
|
||||
@@ -302,6 +302,7 @@
|
||||
"pages/VacayPage.tsx": 13,
|
||||
"pages/atlas/useAtlas.ts": 2,
|
||||
"pages/collections/useCollections.ts": 1,
|
||||
"pages/login/MfaCodeStep.tsx": 4,
|
||||
"pages/sharedTrip/SharedChrome.tsx": 2,
|
||||
"pages/sharedTrip/SharedMap.tsx": 1,
|
||||
"pages/tripPlanner/ListsContainer.tsx": 1,
|
||||
|
||||
@@ -145,7 +145,7 @@ apiClient.interceptors.request.use(
|
||||
)
|
||||
|
||||
export function isAuthPublicPath(pathname: string): boolean {
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password', '/native-handoff']
|
||||
const publicPrefixes = ['/shared/', '/public/']
|
||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-BKFACTS-001 to FE-PLANNER-BKFACTS-022
|
||||
// FE-PLANNER-BKFACTS-001 to FE-PLANNER-BKFACTS-024
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildDay, buildReservation } from '../../../../tests/helpers/factories';
|
||||
import type { ReservationEndpoint } from '../../../types';
|
||||
@@ -83,6 +83,26 @@ describe('bookingFacts: day', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingFacts: hotel times', () => {
|
||||
it('FE-PLANNER-BKFACTS-023: a hotel without times of its own shows the stay check-in and check-out', () => {
|
||||
const f = bookingFacts(buildReservation({
|
||||
type: 'hotel', accommodation_start_day_id: 701, accommodation_end_day_id: 702,
|
||||
accommodation_check_in: '15:00', accommodation_check_in_end: '18:00', accommodation_check_out: '11:00',
|
||||
}), ctx());
|
||||
expect(cell(f, 'reservations.meta.checkIn')).toBe('15:00 → 18:00');
|
||||
expect(cell(f, 'reservations.meta.checkOut')).toBe('11:00');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BKFACTS-024: the booking own times win over the stay times', () => {
|
||||
const f = bookingFacts(buildReservation({
|
||||
type: 'hotel', metadata: JSON.stringify({ check_in_time: '14:00', check_out_time: '10:00' }),
|
||||
accommodation_check_in: '15:00', accommodation_check_in_end: '18:00', accommodation_check_out: '11:00',
|
||||
}), ctx());
|
||||
expect(cell(f, 'reservations.meta.checkIn')).toBe('14:00');
|
||||
expect(cell(f, 'reservations.meta.checkOut')).toBe('10:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingFacts: time', () => {
|
||||
it('FE-PLANNER-BKFACTS-008: start and end time with an arrow', () => {
|
||||
const f = bookingFacts(buildReservation({ reservation_time: '2025-06-01T09:05', reservation_end_time: '2025-06-01T11:30' }), ctx());
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Day, Reservation, ReservationEndpoint } from '../../../types'
|
||||
import { formatPriceText, formatTime, splitReservationDateTime } from '../../../utils/formatters'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../../utils/flightLegs'
|
||||
import { safeExternalHref } from '../../../utils/safeUrl'
|
||||
import { daySpan, parseMeta, type AssignmentLookupEntry } from './bookingsModel'
|
||||
import { daySpan, hotelStayTimes, parseMeta, type AssignmentLookupEntry } from './bookingsModel'
|
||||
|
||||
export interface FactsContext {
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
@@ -105,10 +105,11 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
|
||||
if (!c.hasLinkedCost && meta.price != null && meta.price !== '') {
|
||||
cells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, c.tripCurrency, locale) })
|
||||
}
|
||||
if (meta.check_in_time) {
|
||||
cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` → ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
|
||||
const stay = hotelStayTimes(r, meta)
|
||||
if (stay.checkIn) {
|
||||
cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(stay.checkIn, locale, timeFormat) + (stay.checkInEnd ? ` → ${formatTime(stay.checkInEnd, locale, timeFormat)}` : '') })
|
||||
}
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||
if (stay.checkOut) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(stay.checkOut, locale, timeFormat) })
|
||||
|
||||
// Per-segment codes (#1943), only on a real stopover booking.
|
||||
const legs = r.type === 'flight' ? getFlightLegs(r) : usesStationRoute(r.type) ? getTrainLegs(r) : []
|
||||
|
||||
@@ -103,6 +103,27 @@ export interface DaySpan {
|
||||
}
|
||||
|
||||
/** The days a booking sits on. A hotel reads its stay from the accommodation, which is the source of truth for the range (#1383). */
|
||||
/**
|
||||
* A hotel's check-in window and check-out: the booking's own metadata first,
|
||||
* then the stay it is linked to. Times often live only on the stay, and the
|
||||
* edit form already falls back the same way (reservationFormModel.ts).
|
||||
*/
|
||||
export function hotelStayTimes(
|
||||
r: Reservation,
|
||||
meta: { check_in_time?: string | null; check_in_end_time?: string | null; check_out_time?: string | null },
|
||||
): { checkIn: string | null; checkInEnd: string | null; checkOut: string | null } {
|
||||
if (r.type !== 'hotel') {
|
||||
return { checkIn: meta.check_in_time || null, checkInEnd: meta.check_in_end_time || null, checkOut: meta.check_out_time || null }
|
||||
}
|
||||
const checkIn = meta.check_in_time || r.accommodation_check_in || null
|
||||
return {
|
||||
checkIn,
|
||||
// A window end belongs to the check-in it came with.
|
||||
checkInEnd: meta.check_in_time ? meta.check_in_end_time || null : checkIn ? r.accommodation_check_in_end || null : null,
|
||||
checkOut: meta.check_out_time || r.accommodation_check_out || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function daySpan(r: Reservation, days: Day[]): DaySpan {
|
||||
const byId = (id?: number | null) => (id != null ? days.find(d => d.id === id) : undefined)
|
||||
if (r.type === 'hotel' && (r.accommodation_start_day_id || r.accommodation_end_day_id)) {
|
||||
|
||||
@@ -39,19 +39,24 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('pagesFor', () => {
|
||||
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, false],
|
||||
[3, true],
|
||||
[4, false],
|
||||
[5, false],
|
||||
it('has a page for every day and draws the selection with two days on either side', () => {
|
||||
expect(pagesFor(DAYS, 3).map((p) => [p.dayId, p.active, p.near])).toEqual([
|
||||
[1, false, true],
|
||||
[2, false, true],
|
||||
[3, true, true],
|
||||
[4, false, true],
|
||||
[5, false, true],
|
||||
[6, false, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('has no page beyond the first and last day', () => {
|
||||
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 the same pages whichever day is selected, so a run of swipes never meets an end', () => {
|
||||
expect(pagesFor(DAYS, 1).map((p) => p.key)).toEqual(pagesFor(DAYS, 6).map((p) => p.key));
|
||||
expect(
|
||||
pagesFor(DAYS, 6)
|
||||
.filter((p) => p.near)
|
||||
.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', () => {
|
||||
@@ -69,7 +74,9 @@ 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, true]);
|
||||
expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true, true, true, true]);
|
||||
// Day 6 is three away: its page is there to swipe onto, its content is not drawn yet.
|
||||
expect(screen.queryByText('page 6')).not.toBeInTheDocument();
|
||||
expect(pages[0]).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(pages[1]).not.toHaveAttribute('aria-hidden');
|
||||
});
|
||||
@@ -166,7 +173,7 @@ describe('MDayPager', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('puts the strip back on the current page when the window moves', () => {
|
||||
it('puts the strip on the current page when the selection moves', () => {
|
||||
const { strip, rerender, onSelectDay } = setup(2);
|
||||
// jsdom keeps no scroll offset, so give the strip one to move.
|
||||
let scrollLeft = 0;
|
||||
@@ -199,6 +206,39 @@ describe('MDayPager', () => {
|
||||
expect(strip.scrollLeft).toBe(390);
|
||||
});
|
||||
|
||||
it('moves the strip to a day tapped inside the same window, as on a two-day trip', () => {
|
||||
const twoDays = DAYS.slice(0, 2);
|
||||
const { strip, rerender, onSelectDay } = setup(1, { days: twoDays });
|
||||
let scrollLeft = 0;
|
||||
Object.defineProperty(strip, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get: () => scrollLeft,
|
||||
set: (v) => {
|
||||
scrollLeft = v;
|
||||
},
|
||||
});
|
||||
// Both days are in the window either way; only the current page changes.
|
||||
const realQuery = strip.querySelector.bind(strip);
|
||||
vi.spyOn(strip, 'querySelector').mockImplementation((sel: string) => {
|
||||
const el = realQuery(sel) as HTMLElement | null;
|
||||
if (el && sel === '[data-page-active]') el.getBoundingClientRect = () => ({ left: 390 }) as DOMRect;
|
||||
return el;
|
||||
});
|
||||
strip.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
|
||||
rerender(
|
||||
<MDayPager
|
||||
days={twoDays}
|
||||
selectedDayId={2}
|
||||
onSelectDay={onSelectDay}
|
||||
describeDay={() => ''}
|
||||
renderDay={(dayId) => <p>{`page ${dayId}`}</p>}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(strip.scrollLeft).toBe(390);
|
||||
});
|
||||
|
||||
it('holds still while locked', () => {
|
||||
const { strip } = setup(2, { locked: true });
|
||||
expect(strip).toHaveClass('overflow-x-hidden');
|
||||
|
||||
@@ -4,11 +4,11 @@ import { tickHaptic } from '../../native/haptics';
|
||||
import { pagesFor, type PagerItem } from './dayPagerPages';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Pages through the trip's days the way a native pager does: every day is a
|
||||
* page of a horizontal scroll-snap strip (dayPagerPages.ts), the current one
|
||||
* and two on either side drawn, so the finger drags real paper, the neighbour
|
||||
* slides in with it, and swipes made while the last one still snaps carry on
|
||||
* as far as the trip goes. 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.
|
||||
*
|
||||
@@ -16,8 +16,7 @@ import { pagesFor, type PagerItem } from './dayPagerPages';
|
||||
* 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.
|
||||
* the selection moves and the days around it draw their content.
|
||||
* Keys follow the day ids, so the page that slid in keeps its state (scroll
|
||||
* position, fetched route and weather) as it becomes the current one.
|
||||
*
|
||||
@@ -71,7 +70,10 @@ export default function MDayPager({
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const pages = pagesFor(days, selectedDayId);
|
||||
const windowKey = pages.map((p) => p.key).join(',');
|
||||
// Which page is current belongs in the key: on a trip of up to five days a
|
||||
// chip tap can keep the same pages, and the strip then stayed on the old day
|
||||
// while the rail already showed the new one.
|
||||
const windowKey = pages.map((p) => (p.active ? `*${p.key}` : p.key)).join(',');
|
||||
|
||||
// The handlers below are attached once, so they read the props through here.
|
||||
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId, onPreview });
|
||||
@@ -91,7 +93,7 @@ export default function MDayPager({
|
||||
if (Math.abs(offset) > 0.5) strip.scrollLeft += offset;
|
||||
}, []);
|
||||
|
||||
// Before paint whenever the window moves: a landed swipe or a chip tap.
|
||||
// Before paint whenever the window or its current page moves: a landed swipe or a chip tap.
|
||||
useLayoutEffect(() => {
|
||||
requested.current = null;
|
||||
recentre();
|
||||
@@ -212,7 +214,7 @@ export default function MDayPager({
|
||||
aria-hidden={page.active ? undefined : true}
|
||||
className={`relative w-full flex-none snap-center snap-always ${inFlow ? '' : 'h-full'}`}
|
||||
>
|
||||
{renderDay(page.dayId, page.active)}
|
||||
{page.near ? renderDay(page.dayId, page.active) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -46,10 +46,12 @@ describe('MPushPrompt', () => {
|
||||
it('FE-MOB-PUSHPROMPT-001: a moment after signing in the app asks, and turning it on goes the settings card way', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
|
||||
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).not.toBeNull();
|
||||
// Asked once answered, not once shown.
|
||||
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Turn on' }));
|
||||
expect(env.toggle).toHaveBeenCalledTimes(1);
|
||||
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).not.toBeNull();
|
||||
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -77,6 +79,18 @@ describe('MPushPrompt', () => {
|
||||
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-PUSHPROMPT-005: a prompt left unanswered asks again on the next launch', async () => {
|
||||
const first = render(<MPushPrompt />);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PUSH_PROMPT_DELAY_MS);
|
||||
});
|
||||
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
|
||||
first.unmount();
|
||||
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PUSHPROMPT-004: signed out, it waits', async () => {
|
||||
useAuthStore.setState({ isAuthenticated: false } as never);
|
||||
await renderAndWait();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BellRing } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useWebPush } from '../../hooks/useWebPush';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { nativePushActive, nativePushEndpoint } from '../../native/push';
|
||||
import { pushChannelOffered } from '../../push/webPush';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -36,9 +37,14 @@ function rememberAsked(): void {
|
||||
* server offers push, and only while this device is not signed up yet. Turning
|
||||
* it on goes the way the settings' push card goes (useWebPush), which brings up
|
||||
* the system's question.
|
||||
*
|
||||
* It counts as asked once the user answers it, not when it opens: a sheet that
|
||||
* went under another one, or an app closed while it was up, asks again. And it
|
||||
* waits for the app's capabilities, which can arrive after the sign-in.
|
||||
*/
|
||||
export default function MPushPrompt(): React.ReactElement | null {
|
||||
const signedIn = useAuthStore((s) => s.isAuthenticated);
|
||||
const { caps } = useNativeCapabilities();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,8 +52,7 @@ export default function MPushPrompt(): React.ReactElement | null {
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void pushChannelOffered().then((offered) => {
|
||||
if (cancelled || !offered || nativePushEndpoint()) return;
|
||||
rememberAsked();
|
||||
if (cancelled || !offered || nativePushEndpoint() || alreadyAsked()) return;
|
||||
setOpen(true);
|
||||
});
|
||||
}, PUSH_PROMPT_DELAY_MS);
|
||||
@@ -55,9 +60,13 @@ export default function MPushPrompt(): React.ReactElement | null {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [signedIn]);
|
||||
}, [signedIn, caps]);
|
||||
|
||||
return open ? <PushPromptSheet onClose={() => setOpen(false)} /> : null;
|
||||
const answered = () => {
|
||||
rememberAsked();
|
||||
setOpen(false);
|
||||
};
|
||||
return open ? <PushPromptSheet onClose={answered} /> : null;
|
||||
}
|
||||
|
||||
function PushPromptSheet({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||
|
||||
@@ -35,8 +35,8 @@ function setup() {
|
||||
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();
|
||||
expect(screen.queryByLabelText('Delete')).toBeNull();
|
||||
expect(screen.queryByLabelText('Done')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SWIPE-002: a swipe towards the start opens the trailing action, a tap on it runs it', () => {
|
||||
@@ -45,7 +45,7 @@ describe('MSwipeRow', () => {
|
||||
touch(row, 'touchmove', 160);
|
||||
touch(row, 'touchmove', 120);
|
||||
touch(row, 'touchend');
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
fireEvent.click(screen.getByLabelText('Delete'));
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('MSwipeRow', () => {
|
||||
touch(row, 'touchstart', 200, 100);
|
||||
touch(row, 'touchmove', 190, 160);
|
||||
touch(row, 'touchend');
|
||||
expect(screen.queryByText('Delete')).toBeNull();
|
||||
expect(screen.queryByLabelText('Delete')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SWIPE-004: a tap on an open row closes it instead of opening the row', () => {
|
||||
@@ -72,7 +72,7 @@ describe('MSwipeRow', () => {
|
||||
touch(row, 'touchstart', 10);
|
||||
touch(row, 'touchmove', 120);
|
||||
touch(row, 'touchend');
|
||||
expect(screen.queryByText('Done')).toBeNull();
|
||||
expect(screen.queryByLabelText('Done')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SWIPE-006: swiped through past most of the row, the first action runs at once', () => {
|
||||
|
||||
@@ -34,10 +34,18 @@ interface MSwipeRowProps {
|
||||
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;
|
||||
/**
|
||||
* How far the buttons sit in from the row's top and bottom edge. A row that is a
|
||||
* card of its own leaves it at 0, so the buttons are as tall as the card; a row
|
||||
* inside a grouped card insets them, so they do not touch its dividers.
|
||||
*/
|
||||
inset?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BUTTON_W = 76;
|
||||
/** One button, and the gap before it: the buttons are cards of their own beside the row. */
|
||||
const BUTTON_W = 70;
|
||||
const GAP = 6;
|
||||
const EDGE_PX = 24;
|
||||
const CLAIM_PX = 10;
|
||||
const AXIS_BIAS = 1.5;
|
||||
@@ -59,6 +67,7 @@ export default function MSwipeRow({
|
||||
disabled = false,
|
||||
className = '',
|
||||
surface = 'bg-[color:var(--m-card)]',
|
||||
inset = 0,
|
||||
children,
|
||||
}: MSwipeRowProps) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
@@ -91,7 +100,11 @@ export default function MSwipeRow({
|
||||
// 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;
|
||||
which === 'leading'
|
||||
? latest.current.leading
|
||||
? BUTTON_W + GAP
|
||||
: 0
|
||||
: latest.current.trailing.length * (BUTTON_W + GAP);
|
||||
|
||||
const close = () => {
|
||||
place(0, true);
|
||||
@@ -194,17 +207,18 @@ export default function MSwipeRow({
|
||||
action.onAction();
|
||||
};
|
||||
const shown = open ?? side;
|
||||
// The icon alone, as in Mail; the label names the button for a screen reader.
|
||||
const button = (action: SwipeAction) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
data-swipe-action=""
|
||||
tabIndex={open ? 0 : -1}
|
||||
aria-label={action.label}
|
||||
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]}`}
|
||||
className={`flex h-full w-[70px] flex-none items-center justify-center ${inset ? 'rounded-xl' : 'rounded-2xl'} ${TONE[action.tone]}`}
|
||||
>
|
||||
<action.icon size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||
{action.label}
|
||||
<action.icon size={16} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -213,7 +227,8 @@ export default function MSwipeRow({
|
||||
{shown && (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={`absolute inset-0 flex ${shown === 'leading' ? 'justify-start' : 'justify-end'}`}
|
||||
className={`absolute inset-x-0 flex gap-[6px] ${shown === 'leading' ? 'justify-start pe-[6px]' : 'justify-end ps-[6px]'}`}
|
||||
style={{ top: inset, bottom: inset }}
|
||||
>
|
||||
{shown === 'leading' ? leading && button(leading) : trailing.map(button)}
|
||||
</div>
|
||||
|
||||
@@ -7,29 +7,28 @@ interface Page {
|
||||
key: string;
|
||||
dayId: number | null;
|
||||
active: boolean;
|
||||
/** Drawn with its content; the pages further out are empty paper of the same width. */
|
||||
near: 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.
|
||||
*/
|
||||
/** Pages drawn with their content either side of the selection. */
|
||||
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. */
|
||||
/**
|
||||
* A page for every day, so quick swipes in a row never run into the end of the
|
||||
* strip before the first has settled (they used to spring back after two days).
|
||||
* Only the selection and REACH days either side draw their content. 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.
|
||||
*/
|
||||
export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] {
|
||||
const i = days.findIndex((d) => d.id === selectedDayId);
|
||||
if (i < 0) {
|
||||
const first = days[0];
|
||||
return [
|
||||
{ key: 'none', dayId: selectedDayId, active: true },
|
||||
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []),
|
||||
{ key: 'none', dayId: selectedDayId, active: true, near: true },
|
||||
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false, near: true }] : []),
|
||||
];
|
||||
}
|
||||
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 }));
|
||||
return days.map((d, j) => ({ key: `d${d.id}`, dayId: d.id, active: j === i, near: Math.abs(j - i) <= REACH }));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, Ca
|
||||
import { CardGrid, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import { cardWhen, parseTransportMeta, timeRange } from './transportsModel'
|
||||
import { BOOKING_TYPE_COLOR } from '../../../../components/Planner/bookings/bookingsModel'
|
||||
import { BOOKING_TYPE_COLOR, hotelStayTimes } from '../../../../components/Planner/bookings/bookingsModel'
|
||||
import { useReservationListFilter } from '../../../../components/Planner/bookings/useReservationListFilter'
|
||||
import { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard'
|
||||
import { orderedEndpoints } from '../../../../utils/flightLegs'
|
||||
@@ -119,13 +119,18 @@ function BookingCard({ res, planner, canEdit, compact, onView }: {
|
||||
if (meta.price != null && meta.price !== '') {
|
||||
metaCells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, planner.trip?.currency, locale) })
|
||||
}
|
||||
if (meta.check_in_time) {
|
||||
metaCells.push({
|
||||
const stay = hotelStayTimes(res, meta)
|
||||
// A hotel's times are its check-in and check-out; they come first, so a price
|
||||
// or a seat cannot push them off the card.
|
||||
const stayCells: { label: string; value: string }[] = []
|
||||
if (stay.checkIn) {
|
||||
stayCells.push({
|
||||
label: t('reservations.meta.checkIn'),
|
||||
value: timeRange(meta.check_in_time, meta.check_in_end_time ?? null, locale, timeFormat),
|
||||
value: timeRange(stay.checkIn, stay.checkInEnd, locale, timeFormat),
|
||||
})
|
||||
}
|
||||
if (meta.check_out_time) metaCells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||
if (stay.checkOut) stayCells.push({ label: t('reservations.meta.checkOut'), value: formatTime(stay.checkOut, locale, timeFormat) })
|
||||
metaCells.unshift(...stayCells)
|
||||
|
||||
const files = filesFor(res, planner.files || [])
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useExpenseLinks } from '../../../../components/Budget/useExpenseLinks'
|
||||
import { finalBudgetFor, finalBudgetSources, formerParticipantRows, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import MSwipeRow, { type SwipeAction } from '../../../components/MSwipeRow'
|
||||
import { CountPill, TabPanes } from './tabChrome'
|
||||
import { AddPaymentSheet, MemberAvatar } from './MCostsPayment'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
@@ -595,17 +594,10 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
const members = item.members || []
|
||||
const note = readUserNote(item)
|
||||
|
||||
// A swipe offers what the buttons beside the card do; delete still asks first.
|
||||
const swipe: SwipeAction[] = canEdit
|
||||
? [
|
||||
{ label: t('common.delete'), icon: Trash2, tone: 'danger', onAction: onDelete },
|
||||
{ label: t('common.edit'), icon: Pencil, tone: 'neutral', onAction: onEdit },
|
||||
]
|
||||
: []
|
||||
|
||||
// No swipe here: edit and delete stand beside the card already, and a swipe
|
||||
// put a second, larger pair of the same buttons next to them.
|
||||
return (
|
||||
<MSwipeRow trailing={swipe} disabled={!canEdit} surface="bg-[color:var(--m-bg)]" className="mt-2 rounded-2xl">
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<div className="mt-2 flex items-center gap-[6px]">
|
||||
<div className="relative min-w-0 flex-1 rounded-2xl bg-m-card px-3 pb-[10px] pt-[22px]" style={{ border: `1.5px solid ${borderColor}` }}>
|
||||
<span
|
||||
className="absolute -start-[1.5px] -top-[1.5px] flex items-center gap-1 rounded-es-none rounded-ee-[12px] rounded-ss-[15px] rounded-se-none px-[11px] pb-[4px] pt-[3px] font-geist text-[0.5625rem] font-extrabold uppercase tracking-[.05em] text-white"
|
||||
@@ -688,7 +680,6 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MSwipeRow>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -714,6 +714,7 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
leading={swipeLeading}
|
||||
trailing={swipeTrailing}
|
||||
surface="bg-m-sheetop"
|
||||
inset={4}
|
||||
className="border-t border-[color:var(--m-rowbr)] first:border-t-0"
|
||||
>
|
||||
<div className="flex items-center gap-[9px] py-2">
|
||||
|
||||
@@ -44,10 +44,14 @@ describe('requestHandoffCode', () => {
|
||||
});
|
||||
|
||||
describe('handoffUrl', () => {
|
||||
it('points at the hand-off page with the challenge and an optional start', () => {
|
||||
expect(handoffUrl('https://trek.example.com', 'c')).toBe('https://trek.example.com/native-handoff?challenge=c');
|
||||
it('starts at the server page in front of the hand-off page, with the challenge and an optional start', () => {
|
||||
// Under /api, which every TREK service worker passes to the server: an older
|
||||
// version's worker must not answer the hand-off page from its cache.
|
||||
expect(handoffUrl('https://trek.example.com', 'c')).toBe(
|
||||
'https://trek.example.com/api/auth/native/start?challenge=c'
|
||||
);
|
||||
expect(handoffUrl('https://trek.example.com', 'c', 'oidc')).toBe(
|
||||
'https://trek.example.com/native-handoff?challenge=c&start=oidc'
|
||||
'https://trek.example.com/api/auth/native/start?challenge=c&start=oidc'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -59,7 +63,7 @@ describe('signInThroughBrowser', () => {
|
||||
await signInThroughBrowser('oidc');
|
||||
|
||||
const opened = new URL(vi.mocked(TrekShell.authenticate).mock.calls[0][0].url);
|
||||
expect(opened.pathname).toBe('/native-handoff');
|
||||
expect(opened.pathname).toBe('/api/auth/native/start');
|
||||
expect(opened.searchParams.get('start')).toBe('oidc');
|
||||
expect(vi.mocked(TrekShell.authenticate).mock.calls[0][0].callbackScheme).toBe('com.liketrek.trek');
|
||||
const { code, verifier } = vi.mocked(nativeAuthApi.exchange).mock.calls[0][0];
|
||||
|
||||
@@ -21,6 +21,13 @@ import { APP_URL_SCHEME, TrekShell } from './trekShell';
|
||||
|
||||
export const HANDOFF_PATH = '/native-handoff';
|
||||
|
||||
/**
|
||||
* Where the browser is sent first: a page of the server's that takes an older
|
||||
* TREK's service worker off the browser and then opens HANDOFF_PATH with the
|
||||
* same query. Under /api, which every TREK service worker passes to the server.
|
||||
*/
|
||||
const HANDOFF_START_PATH = '/api/auth/native/start';
|
||||
|
||||
/** Where the browser starts: straight into SSO, or straight at the passkey prompt. */
|
||||
export type BrowserSignInStart = 'oidc' | 'passkey';
|
||||
|
||||
@@ -65,7 +72,7 @@ export function createHandoffSecret(): { verifier: string; challenge: string } {
|
||||
}
|
||||
|
||||
export function handoffUrl(origin: string, challenge: string, start?: BrowserSignInStart, invite?: string): string {
|
||||
const url = new URL(HANDOFF_PATH, origin);
|
||||
const url = new URL(HANDOFF_START_PATH, origin);
|
||||
url.searchParams.set('challenge', challenge);
|
||||
if (start) url.searchParams.set('start', start);
|
||||
if (invite) url.searchParams.set('invite', invite);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BROWSER_SIGN_IN_KEY,
|
||||
BROWSER_SIGN_IN_TTL_MS,
|
||||
browserSignInRunning,
|
||||
endBrowserSignIn,
|
||||
} from './browserSignInFlag';
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe('browserSignInRunning', () => {
|
||||
it('FE-NATIVE-SIGNINFLAG-001: counts a mark from public/native-handoff.js for as long as a sign-in may take', () => {
|
||||
const now = 1_000_000_000;
|
||||
expect(browserSignInRunning(now)).toBe(false);
|
||||
|
||||
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now - 1000));
|
||||
expect(browserSignInRunning(now)).toBe(true);
|
||||
|
||||
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now - BROWSER_SIGN_IN_TTL_MS));
|
||||
expect(browserSignInRunning(now)).toBe(false);
|
||||
// A clock set back, or anything that is no time at all.
|
||||
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now + 60_000));
|
||||
expect(browserSignInRunning(now)).toBe(false);
|
||||
localStorage.setItem(BROWSER_SIGN_IN_KEY, 'soon');
|
||||
expect(browserSignInRunning(now)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-SIGNINFLAG-002: ends with the hand-back to the app', () => {
|
||||
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(Date.now()));
|
||||
endBrowserSignIn();
|
||||
expect(localStorage.getItem(BROWSER_SIGN_IN_KEY)).toBeNull();
|
||||
expect(browserSignInRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Marks the app's sign-in in the system browser while it runs. The page in front
|
||||
* of the handoff page (public/native-handoff.js) sets it, so that until the user
|
||||
* is handed back to the app no service worker is registered (public/registerSW.js)
|
||||
* and no new version reloads the page (utils/versionHandover.ts). A browser tab
|
||||
* still open on an older TREK keeps that version's worker alive, and a worker
|
||||
* registered or a reload in the middle of the sign-in would bring it back to
|
||||
* answer the next page with an app that has no handoff page.
|
||||
*/
|
||||
export const BROWSER_SIGN_IN_KEY = 'trek_native_signin';
|
||||
|
||||
/** As long as a sign-in in the browser may take; the mark counts for no longer. */
|
||||
export const BROWSER_SIGN_IN_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
export function browserSignInRunning(now = Date.now()): boolean {
|
||||
try {
|
||||
const started = Number(localStorage.getItem(BROWSER_SIGN_IN_KEY));
|
||||
return Number.isFinite(started) && started > 0 && now - started >= 0 && now - started < BROWSER_SIGN_IN_TTL_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The sign-in is handed back to the app: the browser goes back to normal. */
|
||||
export function endBrowserSignIn(): void {
|
||||
try {
|
||||
localStorage.removeItem(BROWSER_SIGN_IN_KEY);
|
||||
} catch {
|
||||
/* site data blocked: the mark runs out by itself */
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,29 @@ describe('keepFieldInView', () => {
|
||||
expect(scroller.style.paddingBottom).toBe('');
|
||||
});
|
||||
|
||||
it('FE-NATIVE-KBFIELD-004: a page as tall as the screen gets room at its end and scrolls the field above the keyboard', () => {
|
||||
// The login on an iPhone: no pane scrolls, and the keyboard plugin holds the body's height.
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 1000 });
|
||||
const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation(() => {});
|
||||
const form = document.createElement('form');
|
||||
document.body.appendChild(form);
|
||||
field(form, 'input', 760, 800);
|
||||
|
||||
revealFocusedField(KEYBOARD);
|
||||
const room = document.body.lastElementChild as HTMLElement;
|
||||
expect(room.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(room.style.height).toBe('300px');
|
||||
expect(scrollBy).toHaveBeenCalledWith({ top: 800 - (700 - 24), behavior: 'smooth' });
|
||||
|
||||
// The next field asks again without stacking a second room.
|
||||
revealFocusedField(KEYBOARD);
|
||||
expect(document.querySelectorAll('[aria-hidden="true"]')).toHaveLength(1);
|
||||
|
||||
releaseRoom();
|
||||
expect(room.isConnected).toBe(false);
|
||||
scrollBy.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-NATIVE-KBFIELD-002: listens for the keyboard coming up and going', () => {
|
||||
const addListener = vi.fn(async () => ({ remove: vi.fn() }));
|
||||
keepFieldInView({ addListener } as never);
|
||||
|
||||
@@ -29,6 +29,13 @@ interface KeyboardEvents {
|
||||
/** The pane given room for the keyboard, and what its bottom padding was before. */
|
||||
let padded: { pane: HTMLElement; before: string } | null = null;
|
||||
|
||||
/**
|
||||
* The room at the end of a page that scrolls in no pane of its own, such as the
|
||||
* login: as tall as the screen, it has nothing to scroll by, and padding on the
|
||||
* body does not help while the keyboard plugin holds the body's height.
|
||||
*/
|
||||
let pageRoom: HTMLElement | null = null;
|
||||
|
||||
function scrollingPane(field: HTMLElement): HTMLElement | null {
|
||||
for (let node = field.parentElement; node; node = node.parentElement) {
|
||||
const overflow = getComputedStyle(node).overflowY;
|
||||
@@ -48,7 +55,14 @@ export function revealFocusedField(keyboardHeight: number, doc: Document = docum
|
||||
|
||||
const pane = scrollingPane(field);
|
||||
if (!pane) {
|
||||
field.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
if (!pageRoom) {
|
||||
releaseRoom();
|
||||
pageRoom = doc.createElement('div');
|
||||
pageRoom.setAttribute('aria-hidden', 'true');
|
||||
pageRoom.style.height = `${keyboardHeight}px`;
|
||||
doc.body.appendChild(pageRoom);
|
||||
}
|
||||
view?.scrollBy({ top: box.bottom - (visibleBottom - AIR), behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
if (!padded || padded.pane !== pane) {
|
||||
@@ -61,6 +75,8 @@ export function revealFocusedField(keyboardHeight: number, doc: Document = docum
|
||||
|
||||
/** Takes the room for the keyboard away again. */
|
||||
export function releaseRoom(): void {
|
||||
pageRoom?.remove();
|
||||
pageRoom = null;
|
||||
if (!padded) return;
|
||||
padded.pane.style.paddingBottom = padded.before;
|
||||
padded = null;
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Directory, Filesystem } from '@capacitor/filesystem';
|
||||
import { Share } from '@capacitor/share';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cacheFileName, fileNameFromUrl, shareFile } from './nativeFiles';
|
||||
import { hasNativeFeature } from './capabilities';
|
||||
import { cacheFileName, fileNameFromUrl, openNativeFile, shareFile } from './nativeFiles';
|
||||
import { TrekShell } from './trekShell';
|
||||
|
||||
vi.mock('@capacitor/filesystem', () => ({ Directory: { Cache: 'CACHE' }, Filesystem: { writeFile: vi.fn() } }));
|
||||
vi.mock('@capacitor/share', () => ({ Share: { share: vi.fn() } }));
|
||||
vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn() }));
|
||||
vi.mock('./trekShell', () => ({ TrekShell: { previewFile: vi.fn() } }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(Filesystem.writeFile).mockReset().mockResolvedValue({ uri: 'file:///cache/trip.pdf' });
|
||||
vi.mocked(Share.share).mockReset().mockResolvedValue({});
|
||||
vi.mocked(TrekShell.previewFile).mockReset();
|
||||
});
|
||||
|
||||
describe('cacheFileName', () => {
|
||||
@@ -51,3 +56,36 @@ describe('shareFile', () => {
|
||||
await expect(shareFile(new Blob(['x']), 'x.txt')).rejects.toThrow('No activity found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('openNativeFile', () => {
|
||||
const pdf = () => new Blob(['%PDF'], { type: 'application/pdf' });
|
||||
|
||||
it('FE-NATIVE-FILES-OPEN-001: shows the file in the system viewer where the app has one, not in the share sheet', async () => {
|
||||
vi.mocked(hasNativeFeature).mockReturnValue(true);
|
||||
vi.mocked(TrekShell.previewFile).mockResolvedValue({ result: 'shown' });
|
||||
|
||||
await openNativeFile(pdf(), 'trip.pdf');
|
||||
|
||||
expect(TrekShell.previewFile).toHaveBeenCalledWith({
|
||||
path: 'file:///cache/trip.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
title: 'trip.pdf',
|
||||
});
|
||||
expect(Share.share).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-NATIVE-FILES-OPEN-002: falls back to the share sheet for an older app, a type nothing shows, or a viewer that fails', async () => {
|
||||
vi.mocked(hasNativeFeature).mockReturnValue(false);
|
||||
await openNativeFile(pdf(), 'trip.pdf');
|
||||
expect(TrekShell.previewFile).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(hasNativeFeature).mockReturnValue(true);
|
||||
vi.mocked(TrekShell.previewFile).mockResolvedValueOnce({ result: 'unsupported' });
|
||||
await openNativeFile(pdf(), 'trip.pdf');
|
||||
vi.mocked(TrekShell.previewFile).mockRejectedValueOnce(new Error('no window'));
|
||||
await openNativeFile(pdf(), 'trip.pdf');
|
||||
|
||||
expect(Share.share).toHaveBeenCalledTimes(3);
|
||||
expect(Share.share).toHaveBeenCalledWith({ files: ['file:///cache/trip.pdf'] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Directory, Filesystem } from '@capacitor/filesystem';
|
||||
import { Share } from '@capacitor/share';
|
||||
import { toBase64 } from './blobBase64';
|
||||
import { hasNativeFeature } from './capabilities';
|
||||
import { TrekShell } from './trekShell';
|
||||
import { addToWallet, isWalletPassName } from './wallet';
|
||||
|
||||
/** Readable, but safe as a single path segment inside the cache directory. */
|
||||
@@ -29,11 +31,43 @@ export function fileNameFromUrl(url: string): string {
|
||||
export async function shareFile(blob: Blob, filename: string): Promise<void> {
|
||||
// A Wallet pass goes to Apple Wallet's own sheet, not to the generic share sheet.
|
||||
if (isWalletPassName(filename) && (await addToWallet(blob))) return;
|
||||
await shareCached(await writeToCache(blob, filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file to look at it: a PDF to page through, an image, a document, in
|
||||
* the system's viewer (Quick Look on iOS, the viewing app on Android). An app
|
||||
* without the viewer, or a type nothing on the device shows, gets the share
|
||||
* sheet as before, which still previews and saves it.
|
||||
*/
|
||||
export async function openNativeFile(blob: Blob, filename: string): Promise<void> {
|
||||
if (isWalletPassName(filename) && (await addToWallet(blob))) return;
|
||||
const uri = await writeToCache(blob, filename);
|
||||
if (hasNativeFeature('filePreview')) {
|
||||
try {
|
||||
const { result } = await TrekShell.previewFile({
|
||||
path: uri,
|
||||
mimeType: blob.type || 'application/octet-stream',
|
||||
title: filename,
|
||||
});
|
||||
if (result === 'shown') return;
|
||||
} catch {
|
||||
/* the viewer could not open it: the share sheet still can */
|
||||
}
|
||||
}
|
||||
await shareCached(uri);
|
||||
}
|
||||
|
||||
async function writeToCache(blob: Blob, filename: string): Promise<string> {
|
||||
const { uri } = await Filesystem.writeFile({
|
||||
path: cacheFileName(filename),
|
||||
data: await toBase64(blob),
|
||||
directory: Directory.Cache,
|
||||
});
|
||||
return uri;
|
||||
}
|
||||
|
||||
async function shareCached(uri: string): Promise<void> {
|
||||
try {
|
||||
await Share.share({ files: [uri] });
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { candidateUrls, probeDemo, probeServer, type ProbeResponse } from './ser
|
||||
|
||||
const features = (
|
||||
url: string,
|
||||
data: unknown = { bookingImport: false, aiParsing: false, nativeApp: true }
|
||||
data: unknown = { bookingImport: false, aiParsing: false, nativeApp: true, nativeApi: 2 }
|
||||
): ProbeResponse => ({ status: 200, data, url });
|
||||
|
||||
describe('candidateUrls', () => {
|
||||
@@ -26,13 +26,21 @@ describe('candidateUrls', () => {
|
||||
|
||||
describe('probeServer', () => {
|
||||
it('accepts a server at the minimum native API version or above', async () => {
|
||||
const data = { bookingImport: false, nativeApp: true, nativeApi: 1 };
|
||||
const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features', data));
|
||||
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' });
|
||||
for (const nativeApi of [2, 3]) {
|
||||
const data = { bookingImport: false, nativeApp: true, nativeApi };
|
||||
const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features', data));
|
||||
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' });
|
||||
}
|
||||
});
|
||||
|
||||
it('calls a server below the minimum native API version too old', async () => {
|
||||
const data = { bookingImport: false, nativeApp: true, nativeApi: 0 };
|
||||
const data = { bookingImport: false, nativeApp: true, nativeApi: 1 };
|
||||
const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features', data));
|
||||
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: false, reason: 'tooOld' });
|
||||
});
|
||||
|
||||
it('calls a server that speaks to the app but reports no native API version too old', async () => {
|
||||
const data = { bookingImport: false, nativeApp: true };
|
||||
const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features', data));
|
||||
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: false, reason: 'tooOld' });
|
||||
});
|
||||
@@ -173,7 +181,7 @@ describe('probeServer behind mutual TLS', () => {
|
||||
});
|
||||
|
||||
it('connects where the proxy asked and TREK answered, with or without a certificate', async () => {
|
||||
const trek = { bookingImport: false, nativeApp: true, nativeApi: 1 };
|
||||
const trek = { bookingImport: false, nativeApp: true, nativeApi: 2 };
|
||||
for (const sent of [true, false]) {
|
||||
const get = vi.fn().mockResolvedValue(asked(200, sent, trek));
|
||||
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' });
|
||||
|
||||
@@ -43,10 +43,12 @@ const FEATURES_PATH = '/api/health/features';
|
||||
|
||||
/**
|
||||
* The oldest server API this app can work with (`nativeApi` in the features
|
||||
* response). A server from before the field existed answers without it and is
|
||||
* accepted while the minimum is 1.
|
||||
* response). 2 is TREK 5.1.3, the first server whose sign-in from the app does
|
||||
* not end in the browser. A server without the field is older still. Raising it
|
||||
* goes with SERVER_CHECK in TrekServerStore (iOS and Android), so a server the
|
||||
* app remembers from before is checked against the new minimum once.
|
||||
*/
|
||||
const MIN_NATIVE_API = 1;
|
||||
const MIN_NATIVE_API = 2;
|
||||
|
||||
function normalize(raw: string): string | null {
|
||||
let parsed: URL;
|
||||
@@ -80,7 +82,7 @@ function judge(response: ProbeResponse): ProbeResult {
|
||||
if (response.status !== 200 || !isRecord(response.data)) return { ok: false, reason: 'notTrek' };
|
||||
if (response.data.nativeApp === true) {
|
||||
const api = response.data.nativeApi;
|
||||
if (typeof api === 'number' && api < MIN_NATIVE_API) return { ok: false, reason: 'tooOld' };
|
||||
if (typeof api !== 'number' || api < MIN_NATIVE_API) return { ok: false, reason: 'tooOld' };
|
||||
// Follow a redirect (http to https, a canonical host) so the app loads the
|
||||
// address the server actually answers on.
|
||||
const base = response.url.endsWith(FEATURES_PATH) ? response.url.slice(0, -FEATURES_PATH.length) : null;
|
||||
|
||||
@@ -13,7 +13,12 @@ import type { NativeCapabilities } from './capabilities';
|
||||
* worker.
|
||||
*/
|
||||
export interface TrekShellPlugin {
|
||||
getServer(): Promise<{ url: string | null }>;
|
||||
/**
|
||||
* `previousUrl`: the server this app remembered before an update asked for a
|
||||
* newer one (SERVER_CHECK in TrekServerStore). Set only while no server is,
|
||||
* so the address screen can check it again, pre-filled.
|
||||
*/
|
||||
getServer(): Promise<{ url: string | null; previousUrl?: string | null }>;
|
||||
/**
|
||||
* Remembers the server and loads it. The current page is gone afterwards.
|
||||
* `signInHosts` are the hosts of a proxy's sign-in page in front of it
|
||||
@@ -21,8 +26,15 @@ export interface TrekShellPlugin {
|
||||
* `welcome` is the one-time code of a TREK Cloud purchase: the first load
|
||||
* opens the server's welcome page with it, which signs the buyer in. A build
|
||||
* without this option ignores it and opens the server as usual.
|
||||
* `labels` are the words of the bar the app shows over a proxy's sign-in page,
|
||||
* in the user's language: the server's UI is not there to say them.
|
||||
*/
|
||||
setServer(options: { url: string; signInHosts?: string[]; welcome?: string }): Promise<void>;
|
||||
setServer(options: {
|
||||
url: string;
|
||||
signInHosts?: string[];
|
||||
welcome?: string;
|
||||
labels?: { signInHint: string; changeServer: string };
|
||||
}): Promise<void>;
|
||||
/** Forgets the server and returns to the address screen. */
|
||||
resetServer(): Promise<void>;
|
||||
/** Loads the remembered server again, after it could not be reached. */
|
||||
@@ -103,6 +115,16 @@ export interface TrekShellPlugin {
|
||||
storeFinish(options: { transactionId: string }): Promise<void>;
|
||||
/** iOS, feature wallet: adds a .pkpass (base64) to Apple Wallet, or opens it there when it is already in. */
|
||||
addToWallet(options: { data: string }): Promise<{ result: 'added' | 'opened' | 'cancelled' }>;
|
||||
/**
|
||||
* Shows a file written to the app's cache (a file:// path) in the system's
|
||||
* viewer: Quick Look on iOS, the app that views the type on Android. Feature
|
||||
* "filePreview"; "unsupported" where nothing on the device shows the type.
|
||||
*/
|
||||
previewFile(options: {
|
||||
path: string;
|
||||
mimeType: string;
|
||||
title?: string;
|
||||
}): Promise<{ result: 'shown' | 'unsupported' }>;
|
||||
/** iOS, feature storeKit: the newest active subscription under `prefix` on this Apple ID, if any. */
|
||||
storeRestore(options: { prefix: string }): Promise<{ jws?: string; transactionId?: string }>;
|
||||
/** Feature watch: what the Apple Watch app shows (native/watch.ts), as JSON, sent on through WatchConnectivity. */
|
||||
|
||||
@@ -211,6 +211,96 @@ describe('the richer snapshot', () => {
|
||||
expect(withBookings(plain, [], '2026-11-06', (t) => t)).toBe(plain);
|
||||
});
|
||||
|
||||
it('makes a stop of every leg of a flight with a stopover, and keeps a flight in the air ahead until it lands', () => {
|
||||
const plain = todayOf(
|
||||
trip(1, '2026-11-09', '2026-11-10'),
|
||||
[day(5, 1, '2026-11-09', []), day(6, 2, '2026-11-10', [])],
|
||||
'2026-11-09',
|
||||
() => 'D',
|
||||
(t) => t
|
||||
);
|
||||
if (!plain) throw new Error('the trip runs today');
|
||||
const legs = [
|
||||
{ from: 'SNA', to: 'ATL', dep_day_id: 5, dep_time: '14:00', arr_day_id: 5, arr_time: '21:11' },
|
||||
{ from: 'ATL', to: 'ORF', dep_day_id: 5, dep_time: '22:40', arr_day_id: 6, arr_time: '00:31' },
|
||||
];
|
||||
const today = withBookings(
|
||||
plain,
|
||||
[
|
||||
{ id: 1, trip_id: 1, title: 'Car', type: 'car', status: 'confirmed', reservation_time: '2026-11-09T13:00' },
|
||||
{
|
||||
id: 2,
|
||||
trip_id: 1,
|
||||
title: 'SNA → ATL → ORF',
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
reservation_time: '2026-11-09T14:00',
|
||||
metadata: JSON.stringify({ legs }),
|
||||
},
|
||||
] as never,
|
||||
'2026-11-09',
|
||||
(t) => t
|
||||
);
|
||||
expect(today.stops.map((s) => s.name)).toEqual(['Car', 'SNA → ATL', 'ATL → ORF']);
|
||||
// In the air on the first leg, between the legs, and on the second leg past midnight.
|
||||
expect(tripDayState(today, 16 * 60 + 22, 'done')).toMatchObject({ nextName: 'SNA → ATL', done: 1, total: 3 });
|
||||
expect(tripDayState(today, 21 * 60 + 30, 'done')).toMatchObject({ nextName: 'ATL → ORF', done: 2 });
|
||||
expect(tripDayState(today, 23 * 60 + 30, 'done')).toMatchObject({ nextName: 'ATL → ORF', done: 2 });
|
||||
});
|
||||
|
||||
it('lands a leg without an arrival day on the day it leaves', () => {
|
||||
const plain = todayOf(
|
||||
trip(1, '2026-11-09', '2026-11-09'),
|
||||
[day(5, 1, '2026-11-09', [])],
|
||||
'2026-11-09',
|
||||
() => 'D',
|
||||
(t) => t
|
||||
);
|
||||
if (!plain) throw new Error('the trip runs today');
|
||||
const legs = [
|
||||
{ from: 'SNA', to: 'ATL', dep_day_id: 5, dep_time: '08:00', arr_time: '10:00' },
|
||||
{ from: 'ATL', to: 'ORF', dep_day_id: 5, dep_time: '12:00', arr_time: '14:00' },
|
||||
];
|
||||
const today = withBookings(
|
||||
plain,
|
||||
[
|
||||
{ id: 2, trip_id: 1, title: 'Hop', type: 'flight', status: 'confirmed', metadata: JSON.stringify({ legs }) },
|
||||
] as never,
|
||||
'2026-11-09',
|
||||
(t) => t
|
||||
);
|
||||
expect(tripDayState(today, 15 * 60, 'done')).toMatchObject({ nextName: 'done', done: 2 });
|
||||
});
|
||||
|
||||
it('counts a booking with an end time as under way until it ends', () => {
|
||||
const plain = todayOf(
|
||||
trip(1, '2026-11-09', '2026-11-09'),
|
||||
[day(5, 1, '2026-11-09', [])],
|
||||
'2026-11-09',
|
||||
() => 'D',
|
||||
(t) => t
|
||||
);
|
||||
if (!plain) throw new Error('the trip runs today');
|
||||
const today = withBookings(
|
||||
plain,
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
title: 'Tour',
|
||||
type: 'tour',
|
||||
status: 'confirmed',
|
||||
reservation_time: '2026-11-09T10:00',
|
||||
reservation_end_time: '2026-11-09T13:00',
|
||||
},
|
||||
] as never,
|
||||
'2026-11-09',
|
||||
(t) => t
|
||||
);
|
||||
expect(tripDayState(today, 11 * 60, 'done')).toMatchObject({ nextName: 'Tour', done: 0 });
|
||||
expect(tripDayState(today, 13 * 60 + 1, 'done')).toMatchObject({ nextName: 'done', done: 1 });
|
||||
});
|
||||
|
||||
it('carries the extras and leaves out what is empty', () => {
|
||||
const snapshot = buildSnapshot(
|
||||
{ ...trip(1, '2026-11-06', '2026-11-07'), day_count: 2 } as Trip,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Day, Reservation, Trip } from '../types';
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../utils/flightLegs';
|
||||
import { splitReservationDateTime } from '../utils/formatters';
|
||||
import { heroTrip, sortTripsByRelevance } from '../utils/heroTrip';
|
||||
import { hasNativeFeature } from './capabilities';
|
||||
@@ -56,6 +57,8 @@ interface WidgetStop {
|
||||
minutes: number | null;
|
||||
color?: string | null;
|
||||
symbol?: string | null;
|
||||
/** Minutes after midnight it ends, past midnight as 1440: a flight in the air is not done yet. */
|
||||
endMinutes?: number | null;
|
||||
}
|
||||
|
||||
interface SnapshotStop {
|
||||
@@ -120,6 +123,8 @@ export interface TripDay {
|
||||
path: string;
|
||||
dayLabel: string;
|
||||
stops: WidgetStop[];
|
||||
/** The day's id, which the legs of a multi-leg booking name. */
|
||||
dayId?: number;
|
||||
/** 1-based position of the day in the trip, and the trip's day count. */
|
||||
index?: number;
|
||||
total?: number;
|
||||
@@ -211,6 +216,7 @@ export function todayOf(
|
||||
path: `/trips/${trip.id}`,
|
||||
dayLabel: dayLabel(index + 1, ordered.length),
|
||||
stops,
|
||||
dayId: day.id,
|
||||
index: index + 1,
|
||||
total: ordered.length,
|
||||
};
|
||||
@@ -224,15 +230,38 @@ export function withBookings(
|
||||
formatTime: (time: string) => string
|
||||
): TripDay {
|
||||
const booked = reservations.flatMap((r): WidgetStop[] => {
|
||||
if (r.status === 'cancelled') return [];
|
||||
const symbol = RESERVATION_SYMBOLS[r.type] ?? 'bookmark.fill';
|
||||
// A booking with stopovers is a stop per leg, as the planner shows it: one
|
||||
// stop at the first departure counted the day done while legs were ahead.
|
||||
const legs = r.type === 'flight' ? getFlightLegs(r) : usesStationRoute(r.type) ? getTrainLegs(r) : [];
|
||||
if (legs.length > 1 && day.dayId != null && legs.every((l) => l.dep_day_id != null)) {
|
||||
return legs.flatMap((leg): WidgetStop[] => {
|
||||
if (leg.dep_day_id !== day.dayId || !leg.dep_time) return [];
|
||||
return [
|
||||
{
|
||||
name: leg.from && leg.to ? `${leg.from} → ${leg.to}` : r.title,
|
||||
time: formatTime(leg.dep_time),
|
||||
minutes: minutesOf(leg.dep_time),
|
||||
// A leg without an arrival day lands on the day it leaves, as the planner reads it.
|
||||
endMinutes: (leg.arr_day_id ?? leg.dep_day_id) === day.dayId ? minutesOf(leg.arr_time) : 24 * 60,
|
||||
color: null,
|
||||
symbol,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
const { date, time } = splitReservationDateTime(r.reservation_time);
|
||||
if (r.status === 'cancelled' || !time || date !== today) return [];
|
||||
if (!time || date !== today) return [];
|
||||
const end = splitReservationDateTime(r.reservation_end_time);
|
||||
return [
|
||||
{
|
||||
name: r.title,
|
||||
time: formatTime(time),
|
||||
minutes: minutesOf(time),
|
||||
endMinutes: !end.time ? null : end.date && end.date !== today ? 24 * 60 : minutesOf(end.time),
|
||||
color: null,
|
||||
symbol: RESERVATION_SYMBOLS[r.type] ?? 'bookmark.fill',
|
||||
symbol,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -303,10 +332,15 @@ interface TripDayState {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** The Live Activity's state: the first stop whose time is still ahead, else the last. */
|
||||
/**
|
||||
* The Live Activity's state: the first stop still ahead or still under way (a
|
||||
* flight in the air until it lands), else the last.
|
||||
*/
|
||||
export function tripDayState(day: TripDay, nowMinutes: number, allDone: string): TripDayState {
|
||||
const timed = day.stops.filter((s) => s.minutes != null);
|
||||
const nextIndex = day.stops.findIndex((s) => s.minutes != null && s.minutes >= nowMinutes);
|
||||
const nextIndex = day.stops.findIndex(
|
||||
(s) => s.minutes != null && (s.minutes >= nowMinutes || (s.endMinutes != null && s.endMinutes > nowMinutes))
|
||||
);
|
||||
const done = nextIndex < 0 ? timed.length : day.stops.slice(0, nextIndex).filter((s) => s.minutes != null).length;
|
||||
const next = nextIndex >= 0 ? day.stops[nextIndex] : null;
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
EyeOff,
|
||||
Fingerprint,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Lock,
|
||||
Mail,
|
||||
@@ -18,6 +17,7 @@ import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
|
||||
import { IDP_LOGIN_URL, useLogin } from './login/useLogin';
|
||||
import LoginWorld from './login/LoginWorld';
|
||||
import NativeServerLink from './login/NativeServerLink';
|
||||
import MfaCodeStep from './login/MfaCodeStep';
|
||||
import PasswordChecklist from '../components/shared/PasswordChecklist';
|
||||
|
||||
/** Fixed so the sky does not reshuffle on every render. */
|
||||
@@ -739,71 +739,18 @@ export default function LoginPage(): React.ReactElement {
|
||||
)}
|
||||
|
||||
{mode === 'login' && mfaStep && !passwordChangeStep && (
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
color: '#374151',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{t('login.mfaCodeLabel')}
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<KeyRound
|
||||
size={15}
|
||||
className="text-[#9ca3af]"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 13,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
value={mfaCode}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMfaCode(e.target.value.toUpperCase().slice(0, 24))
|
||||
}
|
||||
placeholder="000000 or XXXX-XXXX"
|
||||
required
|
||||
autoFocus
|
||||
style={inputBase}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => (e.target.style.borderColor = '#111827')}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => (e.target.style.borderColor = '#e5e7eb')}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#9ca3af', marginTop: 8 }}>
|
||||
{t('login.mfaHint')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMfaStep(false);
|
||||
setMfaToken('');
|
||||
setMfaCode('');
|
||||
setError('');
|
||||
}}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#6b7280',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('login.mfaBack')}
|
||||
</button>
|
||||
</div>
|
||||
<MfaCodeStep
|
||||
email={email}
|
||||
code={mfaCode}
|
||||
onCode={setMfaCode}
|
||||
inputStyle={inputBase}
|
||||
onBack={() => {
|
||||
setMfaStep(false);
|
||||
setMfaToken('');
|
||||
setMfaCode('');
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Username (register only) */}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface MfaCodeStepProps {
|
||||
/** The account being signed in, kept in the form for password managers. */
|
||||
email: string;
|
||||
code: string;
|
||||
onCode: (code: string) => void;
|
||||
inputStyle: React.CSSProperties;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/** The login's second step: the one-time or backup code, and the way back. */
|
||||
export default function MfaCodeStep({ email, code, onCode, inputStyle, onBack }: MfaCodeStepProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
color: '#374151',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{t('login.mfaCodeLabel')}
|
||||
</label>
|
||||
{/* The account the code belongs to, for password managers: they match a
|
||||
one-time code to a saved login through the username in the same form,
|
||||
and the email field is gone in this step. */}
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="sr-only"
|
||||
/>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<KeyRound
|
||||
size={15}
|
||||
className="text-[#9ca3af]"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 13,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="totp"
|
||||
name="totp"
|
||||
// Text, not numeric: backup codes carry letters. The hints are set here
|
||||
// rather than left to native/keyboardFields.ts, because autoFocus gives
|
||||
// the field focus before any touch could prepare it.
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="characters"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
enterKeyHint="go"
|
||||
value={code}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onCode(e.target.value.toUpperCase().slice(0, 24))}
|
||||
placeholder={t('login.mfaPlaceholder')}
|
||||
required
|
||||
autoFocus
|
||||
style={inputStyle}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => (e.target.style.borderColor = '#111827')}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => (e.target.style.borderColor = '#e5e7eb')}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#9ca3af', marginTop: 8 }}>
|
||||
{t('login.mfaHint')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#6b7280',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('login.mfaBack')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +102,7 @@ describe('useNativeHandoff', () => {
|
||||
|
||||
it('FE-NATIVE-HANDOFF-006: only mints the code on the tap, then hands it to the app scheme', async () => {
|
||||
vi.mocked(requestHandoffCode).mockResolvedValue('c/1');
|
||||
localStorage.setItem('trek_native_signin', String(Date.now()));
|
||||
const { result } = renderHandoff();
|
||||
expect(requestHandoffCode).not.toHaveBeenCalled();
|
||||
|
||||
@@ -111,6 +112,8 @@ describe('useNativeHandoff', () => {
|
||||
|
||||
expect(requestHandoffCode).toHaveBeenCalledWith(challenge);
|
||||
expect(window.location.href).toBe('com.liketrek.trek://auth?code=c%2F1');
|
||||
// Handed back: the browser registers its service worker again from here.
|
||||
expect(localStorage.getItem('trek_native_signin')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-NATIVE-HANDOFF-007: reports a failed hand-off', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { HANDOFF_PATH, rememberHandoffReturn, requestHandoffCode, signInWithPasskey } from '../../native/browserSignIn';
|
||||
import { endBrowserSignIn } from '../../native/browserSignInFlag';
|
||||
import { APP_URL_SCHEME } from '../../native/trekShell';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
@@ -50,6 +51,7 @@ export function useNativeHandoff() {
|
||||
setStatus('sending');
|
||||
try {
|
||||
const code = await requestHandoffCode(challenge);
|
||||
endBrowserSignIn();
|
||||
window.location.href = `${APP_URL_SCHEME}://auth?code=${encodeURIComponent(code)}`;
|
||||
} catch {
|
||||
setStatus('failed');
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getCachedBlob } from '../db/offlineDb'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { isIosStandalone } from './iosDevice'
|
||||
import { isNativeApp } from '../native/platform'
|
||||
import { fileNameFromUrl, shareFile } from '../native/nativeFiles'
|
||||
import { fileNameFromUrl, openNativeFile, shareFile } from '../native/nativeFiles'
|
||||
import { reportNativeFailure } from '../native/reportFailure'
|
||||
|
||||
// MIME types safe to open inline (will not execute script in any browser).
|
||||
@@ -118,9 +118,9 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
||||
*/
|
||||
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
const blob = await getFileBlob(url)
|
||||
// The share sheet previews PDFs and images too, and a blob tab has nowhere
|
||||
// to open inside the app.
|
||||
if (isNativeApp()) return shareFile(blob, filename ?? fileNameFromUrl(url))
|
||||
// A blob tab has nowhere to open inside the app: the system's viewer shows it,
|
||||
// or the share sheet where the app has none.
|
||||
if (isNativeApp()) return openNativeFile(blob, filename ?? fileNameFromUrl(url))
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
// Force download for MIME types that can execute script when rendered inline
|
||||
|
||||
@@ -447,6 +447,30 @@ describe('reconcileAppVersion: a page loaded past an older worker', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("reconcileAppVersion: during the app's sign-in in the browser", () => {
|
||||
it('FE-UTIL-VERSION-058: leaves version and page alone while the sign-in runs, and acts again after it', async () => {
|
||||
const { container } = installWorker(null)
|
||||
container.getRegistration.mockResolvedValue(undefined)
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
// public/native-handoff.js marks the sign-in; a reload now could land on an
|
||||
// older worker that a tab still open on the old version keeps alive.
|
||||
localStorage.setItem('trek_native_signin', String(Date.now()))
|
||||
|
||||
await reconcileAppVersion('4.3.1')
|
||||
|
||||
expect(marker()).toBe('4.3.0')
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(container.getRegistration).not.toHaveBeenCalled()
|
||||
|
||||
// A mark older than any sign-in takes counts for nothing.
|
||||
localStorage.setItem('trek_native_signin', String(Date.now() - 16 * 60 * 1000))
|
||||
await reconcileAppVersion('4.3.1')
|
||||
expect(marker()).toBe('4.3.1')
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileAppVersion: the reload guard', () => {
|
||||
it('FE-UTIL-VERSION-030: a marker that cannot be written does not turn into a reload loop', async () => {
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* version and session.
|
||||
*/
|
||||
|
||||
import { browserSignInRunning } from '../native/browserSignInFlag'
|
||||
import { reloadFresh } from './chunkReload'
|
||||
import { currentController, servingWorker } from './serviceWorkerShell'
|
||||
|
||||
@@ -198,6 +199,9 @@ function waitForTakeover(reg: ServiceWorkerRegistration, version: string): Promi
|
||||
export async function reconcileAppVersion(reported: unknown): Promise<void> {
|
||||
const version = asReleaseTag(reported)
|
||||
if (!version) return
|
||||
// The app's sign-in runs in this browser: a reload now could land on an older
|
||||
// version's worker that a tab still open keeps alive (native/browserSignInFlag).
|
||||
if (browserSignInRunning()) return
|
||||
|
||||
let storedVersion: string | null
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-CLIENT-INTERCEPTOR-001 to FE-CLIENT-INTERCEPTOR-012
|
||||
// FE-CLIENT-INTERCEPTOR-001 to FE-CLIENT-INTERCEPTOR-015
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isAuthPublicPath } from '../../../src/api/client'
|
||||
|
||||
@@ -19,6 +19,10 @@ describe('FE-CLIENT-INTERCEPTOR: 401 AUTH_REQUIRED redirect allowlist', () => {
|
||||
it('FE-CLIENT-INTERCEPTOR-004: /reset-password', () => {
|
||||
expect(isAuthPublicPath('/reset-password')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-CLIENT-INTERCEPTOR-015: /native-handoff, which signs in by itself', () => {
|
||||
expect(isAuthPublicPath('/native-handoff')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prefix-match public paths — no redirect', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { shareFile } from '../../../src/native/nativeFiles';
|
||||
import { openNativeFile, shareFile } from '../../../src/native/nativeFiles';
|
||||
import { downloadBlob, downloadFile, openFile } from '../../../src/utils/fileDownload';
|
||||
|
||||
vi.mock('../../../src/db/offlineDb', () => ({ getCachedBlob: vi.fn() }));
|
||||
@@ -8,13 +8,14 @@ vi.mock('../../../src/native/nativeFiles', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../src/native/nativeFiles')>(
|
||||
'../../../src/native/nativeFiles'
|
||||
);
|
||||
return { ...actual, shareFile: vi.fn() };
|
||||
return { ...actual, shareFile: vi.fn(), openNativeFile: vi.fn() };
|
||||
});
|
||||
|
||||
const pdf = new Blob(['%PDF'], { type: 'application/pdf' });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(shareFile).mockReset().mockResolvedValue();
|
||||
vi.mocked(openNativeFile).mockReset().mockResolvedValue();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, blob: () => Promise.resolve(pdf) }));
|
||||
});
|
||||
|
||||
@@ -42,8 +43,9 @@ describe('fileDownload inside the native app', () => {
|
||||
expect(shareFile).toHaveBeenLastCalledWith(pdf, 'ticket A.pdf');
|
||||
});
|
||||
|
||||
it('opens files through the share sheet too, since there is no tab to open them in', async () => {
|
||||
it('opens files in the system viewer, since there is no tab to open them in', async () => {
|
||||
await openFile('/api/trips/1/files/2/download');
|
||||
expect(shareFile).toHaveBeenLastCalledWith(pdf, 'download');
|
||||
expect(openNativeFile).toHaveBeenLastCalledWith(pdf, 'download');
|
||||
expect(shareFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+826
-1486
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "5.1.2",
|
||||
"version": "5.1.3",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.6",
|
||||
"knip": "^6.38.0",
|
||||
"knip": "^6.44.1",
|
||||
"unrun": "^0.3.1"
|
||||
},
|
||||
"comment:scarfSettings": "swagger-ui-dist ships @scarf/scarf install-time analytics; TREK sends no telemetry, so it stays off.",
|
||||
@@ -42,8 +42,8 @@
|
||||
},
|
||||
"comment:overrides": "React and react-dom: one React 19 across the workspace, so the test renderer (@testing-library/react) and the app share one react-dom. Everything else is a security floor. npm reads overrides from the root package.json only, so a floor in a workspace package.json does nothing. A floor never crosses a major its dependents ask for (brace-expansion is pinned per line for that reason), and npm ls <name> shows whether it holds.",
|
||||
"overrides": {
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"@hono/node-server": "^1.19.13",
|
||||
"body-parser@^1.20.0": "^1.20.9",
|
||||
"body-parser@^2.0.0": "^2.4.0",
|
||||
|
||||
Generated
+238
-384
File diff suppressed because it is too large
Load Diff
@@ -78,9 +78,9 @@
|
||||
"@types/node": "^22.20.4",
|
||||
"@types/semver": "^7.8.0",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^5.1.0"
|
||||
},
|
||||
"keywords": [
|
||||
"trek",
|
||||
|
||||
@@ -530,6 +530,9 @@ export const PLUGIN_ENTITY_FIELDS = {
|
||||
'accommodation_name',
|
||||
'accommodation_start_day_id',
|
||||
'accommodation_end_day_id',
|
||||
'accommodation_check_in',
|
||||
'accommodation_check_in_end',
|
||||
'accommodation_check_out',
|
||||
'day_positions',
|
||||
'endpoints',
|
||||
'travelers',
|
||||
|
||||
+23
-23
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "5.1.2",
|
||||
"version": "5.1.3",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -57,22 +57,22 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-lite/client": "^0.23.7",
|
||||
"@aws-lite/client": "^0.24.1",
|
||||
"@aws-lite/s3": "^0.2.7",
|
||||
"@mikro-orm/cli": "^7.2.1",
|
||||
"@mikro-orm/core": "^7.2.1",
|
||||
"@mikro-orm/migrations": "^7.2.1",
|
||||
"@mikro-orm/cli": "^7.2.7",
|
||||
"@mikro-orm/core": "^7.2.7",
|
||||
"@mikro-orm/migrations": "^7.2.7",
|
||||
"@mikro-orm/nestjs": "^7.1.0",
|
||||
"@mikro-orm/seeder": "^7.2.1",
|
||||
"@mikro-orm/seeder": "^7.2.7",
|
||||
"@mikro-orm/sql": "^7.2.1",
|
||||
"@mikro-orm/sqlite": "^7.2.1",
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@mikro-orm/sqlite": "^7.2.7",
|
||||
"@modelcontextprotocol/sdk": "^1.32.2",
|
||||
"@nestjs/common": "^11.2.5",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.2.5",
|
||||
"@nestjs/platform-express": "^11.2.5",
|
||||
"@nestjs/platform-ws": "^11.2.5",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/schedule": "^12.0.3",
|
||||
"@nestjs/swagger": "^11.4.7",
|
||||
"@nestjs/websockets": "^11.2.5",
|
||||
"@simplewebauthn/server": "^13.3.3",
|
||||
@@ -87,7 +87,7 @@
|
||||
"dotenv": "^16.6.1",
|
||||
"exifr": "7.1.3",
|
||||
"express": "^4.22.3",
|
||||
"fast-xml-parser": "^5.11.1",
|
||||
"fast-xml-parser": "^5.12.0",
|
||||
"helmet": "^8.3.0",
|
||||
"jimp": "^1.6.1",
|
||||
"json5": "^2.2.3",
|
||||
@@ -95,30 +95,30 @@
|
||||
"kysely": "^0.29.6",
|
||||
"multer": "^2.5.0",
|
||||
"nestjs-zod": "^5.5.0",
|
||||
"nodemailer": "^10.1.4",
|
||||
"nodemailer": "^10.3.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.8.5",
|
||||
"semver": "^7.8.6",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^6.0.3",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"undici": "^7.30.1",
|
||||
"unzipper": "^0.12.5",
|
||||
"uuid": "^14.0.2",
|
||||
"ws": "^8.21.3",
|
||||
"uuid": "^14.0.3",
|
||||
"ws": "^8.22.1",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-lite/s3-types": "^0.2.8",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@mikro-orm/entity-generator": "^7.2.1",
|
||||
"@mikro-orm/postgresql": "^7.2.1",
|
||||
"@mikro-orm/entity-generator": "^7.2.7",
|
||||
"@mikro-orm/postgresql": "^7.2.7",
|
||||
"@nestjs/testing": "^11.2.5",
|
||||
"@swc/core": "^1.16.2",
|
||||
"@swc/core": "^1.16.22",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -128,22 +128,22 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/multer": "^2.3.0",
|
||||
"@types/node": "^25.9.8",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/semver": "^7.8.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/ws": "^8.18.2",
|
||||
"@vitest/coverage-istanbul": "^4.1.11",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-flat-gitignore": "^2.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.9.8",
|
||||
"prettier": "^3.9.10",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"supertest": "^7.3.1",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
"unplugin-swc": "^1.6.0",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"scripts/db-call-graph.mjs": 1036,
|
||||
"scripts/generate-entities.ts": 2259,
|
||||
"src/db/repositories/Places.repository.ts": 1437,
|
||||
"src/db/repositories/Reservations.repository.ts": 1604,
|
||||
"src/db/repositories/Reservations.repository.ts": 1559,
|
||||
"src/db/repositories/Trips.repository.ts": 1121,
|
||||
"src/db/repositories/Users.repository.ts": 1445,
|
||||
"src/nest/atlas/atlas.service.ts": 1177,
|
||||
@@ -15,6 +15,6 @@
|
||||
"src/nest/places/places.service.ts": 1549,
|
||||
"src/nest/plugins/plugin-runtime.service.ts": 1460,
|
||||
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
|
||||
"src/nest/reservations/reservations.service.ts": 1237,
|
||||
"src/nest/reservations/reservations.service.ts": 1228,
|
||||
"src/nest/vacay/vacay.service.ts": 1409
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import type {
|
||||
PublicApiUnplannedPlaceRow,
|
||||
PublicApiUnscheduledReservationRow,
|
||||
} from './reservation-public-api-rows';
|
||||
import type { ReservationAllColumnsRow, ReservationJoinRow } from './reservation-rows';
|
||||
|
||||
// The reservation row shapes live in their own module; re-exported so callers keep one import path.
|
||||
export type { ReservationAllColumnsRow, ReservationJoinRow } from './reservation-rows';
|
||||
// The public API row types moved to their own module; re-exported so callers keep one import path.
|
||||
export type {
|
||||
PublicApiAccommodationRow,
|
||||
@@ -27,26 +30,6 @@ export type {
|
||||
PublicApiUnscheduledReservationRow,
|
||||
};
|
||||
|
||||
/**
|
||||
* RS18/RR1's shared joined projection — `SELECT r.*, d.day_number, p.name as
|
||||
* place_name, r.assignment_id, ap.place_id as accommodation_place_id,
|
||||
* acc_p.name as accommodation_name, ap.start_day_id as
|
||||
* accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id FROM
|
||||
* reservations r LEFT JOIN days d ON r.day_id = d.id LEFT JOIN places p ON
|
||||
* r.place_id = p.id LEFT JOIN day_accommodations ap ON r.accommodation_id =
|
||||
* ap.id LEFT JOIN places acc_p ON ap.place_id = acc_p.id`. The service
|
||||
* folds `accommodation_id` (`Math.trunc(Number(x))`), `day_positions`,
|
||||
* `endpoints` and `travelers` on afterward (unchanged from the legacy split).
|
||||
*/
|
||||
export interface ReservationJoinRow extends ReservationAllColumnsRow {
|
||||
day_number: number | null;
|
||||
place_name: string | null;
|
||||
accommodation_place_id: number | null;
|
||||
accommodation_name: string | null;
|
||||
accommodation_start_day_id: number | null;
|
||||
accommodation_end_day_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kysely typing for the RS18/RR1 join (`joinedQuery` below) — `reservations
|
||||
* .accommodation_id` is a bare `p.text().nullable()` column with no FK to
|
||||
@@ -394,6 +377,9 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
|
||||
'acc_p.name as accommodation_name',
|
||||
'ap.start_day_id as accommodation_start_day_id',
|
||||
'ap.end_day_id as accommodation_end_day_id',
|
||||
'ap.check_in as accommodation_check_in',
|
||||
'ap.check_in_end as accommodation_check_in_end',
|
||||
'ap.check_out as accommodation_check_out',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1526,34 +1512,3 @@ export interface RoadtripCarrierRow {
|
||||
metadata: string | null;
|
||||
day_plan_position: number | null;
|
||||
}
|
||||
|
||||
/** `SELECT *` — every scalar column of `Reservations`, RS35's shape. */
|
||||
export interface ReservationAllColumnsRow {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
day_id: number | null;
|
||||
end_day_id: number | null;
|
||||
place_id: number | null;
|
||||
assignment_id: number | null;
|
||||
title: string;
|
||||
accommodation_id: string | null;
|
||||
reservation_time: string | null;
|
||||
reservation_end_time: string | null;
|
||||
location: string | null;
|
||||
confirmation_number: string | null;
|
||||
notes: string | null;
|
||||
status: string | null;
|
||||
type: string | null;
|
||||
created_at: string | null;
|
||||
metadata: string | null;
|
||||
day_plan_position: number | null;
|
||||
needs_review: number;
|
||||
external_source: string | null;
|
||||
external_id: string | null;
|
||||
external_owner_user_id: number | null;
|
||||
external_synced_at: string | null;
|
||||
sync_enabled: number | null;
|
||||
external_hash: string | null;
|
||||
url: string | null;
|
||||
ingest_state: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The row shapes ReservationsRepository reads: every column of a reservation,
|
||||
* and the joined projection the trip list and the single reads share.
|
||||
*/
|
||||
|
||||
/** `SELECT *` — every scalar column of `Reservations`, RS35's shape. */
|
||||
export interface ReservationAllColumnsRow {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
day_id: number | null;
|
||||
end_day_id: number | null;
|
||||
place_id: number | null;
|
||||
assignment_id: number | null;
|
||||
title: string;
|
||||
accommodation_id: string | null;
|
||||
reservation_time: string | null;
|
||||
reservation_end_time: string | null;
|
||||
location: string | null;
|
||||
confirmation_number: string | null;
|
||||
notes: string | null;
|
||||
status: string | null;
|
||||
type: string | null;
|
||||
created_at: string | null;
|
||||
metadata: string | null;
|
||||
day_plan_position: number | null;
|
||||
needs_review: number;
|
||||
external_source: string | null;
|
||||
external_id: string | null;
|
||||
external_owner_user_id: number | null;
|
||||
external_synced_at: string | null;
|
||||
sync_enabled: number | null;
|
||||
external_hash: string | null;
|
||||
url: string | null;
|
||||
ingest_state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RS18/RR1's shared joined projection — `SELECT r.*, d.day_number, p.name as
|
||||
* place_name, r.assignment_id, ap.place_id as accommodation_place_id,
|
||||
* acc_p.name as accommodation_name, ap.start_day_id as
|
||||
* accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id,
|
||||
* ap.check_in/check_in_end/check_out as accommodation_check_in/_check_in_end/_check_out FROM
|
||||
* reservations r LEFT JOIN days d ON r.day_id = d.id LEFT JOIN places p ON
|
||||
* r.place_id = p.id LEFT JOIN day_accommodations ap ON r.accommodation_id =
|
||||
* ap.id LEFT JOIN places acc_p ON ap.place_id = acc_p.id`. The service
|
||||
* folds `accommodation_id` (`Math.trunc(Number(x))`), `day_positions`,
|
||||
* `endpoints` and `travelers` on afterward (unchanged from the legacy split).
|
||||
*/
|
||||
export interface ReservationJoinRow extends ReservationAllColumnsRow {
|
||||
day_number: number | null;
|
||||
place_name: string | null;
|
||||
accommodation_place_id: number | null;
|
||||
accommodation_name: string | null;
|
||||
accommodation_start_day_id: number | null;
|
||||
accommodation_end_day_id: number | null;
|
||||
/** The stay's own times, which a hotel booking's card falls back to. */
|
||||
accommodation_check_in: string | null;
|
||||
accommodation_check_in_end: string | null;
|
||||
accommodation_check_out: string | null;
|
||||
}
|
||||
@@ -280,7 +280,11 @@ export function applyGlobalMiddleware(
|
||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
|
||||
// No Google Fonts: every font ships with TREK (client/public/fonts), so no reader's IP goes to Google.
|
||||
styleSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
|
||||
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
||||
// trek-tile: is the iOS app's map tile store (client/src/native/tileCache.ts).
|
||||
// The app's web view runs this page, and WebKit holds a scheme the app
|
||||
// answers itself to img-src as well: without it every raster tile was
|
||||
// refused and the app showed routes on a blank map.
|
||||
imgSrc: ["'self'", 'data:', 'blob:', 'https:', 'trek-tile:'],
|
||||
connectSrc: [
|
||||
"'self'",
|
||||
'ws:',
|
||||
|
||||
@@ -13,6 +13,8 @@ import { activityOf, foundPlacesOf, googleIdsOf, weatherDayOf, weatherOf } from
|
||||
import type { TripCard } from './assistant-context';
|
||||
import { FOLLOW_UPS_ANSWER, followUpsOf } from './assistant-follow-ups';
|
||||
import { outputText } from './assistant-history';
|
||||
import { OPEN_TRIP_CHAT_TOOL } from './assistant-home';
|
||||
import { OPEN_JOURNEY_CHAT_TOOL } from './assistant-home-journeys';
|
||||
import { toolOutputForProvider } from './assistant-output';
|
||||
import { MAX_CARDS_PER_STEP, MAX_READS_PER_STEP, MAX_STEPS, MAX_WRITES_PER_STEP } from './assistant-tools.constants';
|
||||
import type { AssistantToolKind, ToolRun } from './assistant-tools.service';
|
||||
@@ -557,6 +559,12 @@ async function askForTheAnswer(turn: Turn, rowId: number, step: StepRecord & { a
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calls that only hand the conversation on, to a trip's or a journey's own
|
||||
* chat: a card for the user, nothing the model reads further.
|
||||
*/
|
||||
const HANDOFF_TOOLS: ReadonlySet<string> = new Set([OPEN_TRIP_CHAT_TOOL, OPEN_JOURNEY_CHAT_TOOL]);
|
||||
|
||||
/** One step of the turn: the outcome when the turn ends with it, null when the model is asked again. */
|
||||
async function runStep(turn: Turn, step: number): Promise<AgentOutcome | null> {
|
||||
const { deps, input, w, signal, history } = turn;
|
||||
@@ -638,6 +646,19 @@ async function runStep(turn: Turn, step: number): Promise<AgentOutcome | null> {
|
||||
return outcomeOf(turn, 'aborted');
|
||||
}
|
||||
if (held.length > 0) return holdChanges(turn, rowId, { parts, providerState }, held);
|
||||
// A step that wrote its answer and only handed the conversation on is the end of
|
||||
// the turn. Asked once more after the card, the model wrote the same answer again.
|
||||
if (text.trim() && received.every((call) => HANDOFF_TOOLS.has(call.name))) {
|
||||
const followUps = asks.length > 0 ? followUpsOf(asks[asks.length - 1].args) : turn.followUps;
|
||||
if (followUps) {
|
||||
parts.push({ type: 'data-trek-follow-ups', data: followUps });
|
||||
w.followUps(followUps);
|
||||
}
|
||||
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
|
||||
w.finishStep();
|
||||
finishTurn(turn, 'stop');
|
||||
return outcomeOf(turn, 'stop');
|
||||
}
|
||||
const { images, modelTexts } = collected;
|
||||
await carryOn(turn, rowId, { parts, providerState }, { text, calls, images, modelTexts });
|
||||
return null;
|
||||
|
||||
@@ -145,6 +145,9 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [
|
||||
// The native app redeems a one-time code from the system browser here. The
|
||||
// code is only good together with the verifier the app kept to itself.
|
||||
'NativeAuthController.exchange',
|
||||
// Where the app's sign-in in the system browser starts: a page that forwards
|
||||
// to the handoff page and carries no credential.
|
||||
'NativeAuthController.start',
|
||||
'OauthPublicController.revoke',
|
||||
'OauthPublicController.token',
|
||||
'OauthPublicController.userinfo',
|
||||
|
||||
@@ -69,7 +69,10 @@ export class FeaturesController {
|
||||
// The version of what the apps rely on from the server (the hand-off
|
||||
// routes, the shell bridge in the served UI). Raised only for a change an
|
||||
// older app cannot follow; the app refuses a server below its minimum.
|
||||
nativeApi: 1,
|
||||
// 2 (5.1.3): the browser sign-in starts under /api, past an older TREK's
|
||||
// service worker, and the hand-off page signs in by itself. Apps from then
|
||||
// on refuse a server below it, so its users stop landing in the browser.
|
||||
nativeApi: 2,
|
||||
// The other way round: the oldest app binary this server's UI works with,
|
||||
// as the shell API the app reports (TrekShell.getCapabilities). An app
|
||||
// below it is asked to update instead of running native calls it lacks.
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ResponseContract } from '../common/response-contract';
|
||||
import { sessionClientFrom } from '../sessions/sessions.service';
|
||||
import { NativeExchangeDto, NativeHandoffDto } from './native-auth.dto';
|
||||
import { NativeAuthService } from './native-auth.service';
|
||||
import { Body, Controller, HttpCode, HttpException, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, HttpException, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
nativeHandoffResponseSchema,
|
||||
successResponseSchema,
|
||||
@@ -23,6 +23,36 @@ import type { Request, Response } from 'express';
|
||||
const EXCHANGE_WINDOW_MS = 15 * 60 * 1000;
|
||||
const EXCHANGE_MAX = 20;
|
||||
|
||||
const CHALLENGE = /^[0-9a-f]{64}$/;
|
||||
const START = new Set(['oidc', 'passkey']);
|
||||
const INVITE = /^[A-Za-z0-9_-]{1,200}$/;
|
||||
|
||||
/** `&`, `<`, `>` and `"` as entities, for a value inside a double-quoted attribute. */
|
||||
function attr(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* The page /api/auth/native/start answers with: it takes a service worker of an
|
||||
* older TREK off the browser, then opens the handoff page (public/native-handoff.js).
|
||||
* A browser that ran TREK before 5.1.1 keeps that version's app in its service
|
||||
* worker, and the worker answers /native-handoff from its cache with an app that
|
||||
* has no such page: the sign-in ended on the dashboard in the browser instead of
|
||||
* in the app. Every TREK service worker lets /api through to the server, so this
|
||||
* page always comes from the running version.
|
||||
*/
|
||||
export function nativeStartPage(target: string): string {
|
||||
const href = attr(target);
|
||||
return [
|
||||
'<!doctype html>',
|
||||
'<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
'<meta name="robots" content="noindex"><title>TREK</title>',
|
||||
`<noscript><meta http-equiv="refresh" content="0;url=${href}"></noscript>`,
|
||||
`<script src="/native-handoff.js" data-target="${href}"></script>`,
|
||||
'</head><body></body></html>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/auth/native: the sign-in hand-off between the system browser and the
|
||||
* native app. See the contract in @trek/shared for the whole round trip.
|
||||
@@ -47,6 +77,34 @@ export class NativeAuthController {
|
||||
return { code: await this.nativeAuth.issueCode(user.id, body.challenge) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the app's sign-in in the system browser starts. Answers a small page
|
||||
* instead of the handoff page itself (see nativeStartPage); the query is the
|
||||
* one the handoff page takes, checked here so nothing else rides along.
|
||||
*/
|
||||
// response-contract-exempt: an HTML page that forwards the browser, no body to check
|
||||
@Get('start')
|
||||
@Public('opens the handoff page in the browser; it carries no credential and shows nothing')
|
||||
start(
|
||||
@Query('challenge') challenge: unknown,
|
||||
@Query('start') start: unknown,
|
||||
@Query('invite') invite: unknown,
|
||||
@Res() res: Response,
|
||||
): void {
|
||||
if (typeof challenge !== 'string' || !CHALLENGE.test(challenge)) {
|
||||
res.status(400).type('text/plain').send('Invalid sign-in link.');
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ challenge });
|
||||
if (typeof start === 'string' && START.has(start)) params.set('start', start);
|
||||
if (typeof invite === 'string' && INVITE.test(invite)) params.set('invite', invite);
|
||||
res
|
||||
.status(200)
|
||||
.set('Cache-Control', 'no-store')
|
||||
.type('text/html')
|
||||
.send(nativeStartPage(`/native-handoff?${params.toString()}`));
|
||||
}
|
||||
|
||||
@Post('exchange')
|
||||
@Public('the one-time code together with its verifier is the credential')
|
||||
@HttpCode(200)
|
||||
|
||||
@@ -170,6 +170,9 @@ export const PLUGIN_ENTITY_CONTRACT = {
|
||||
'accommodation_name',
|
||||
'accommodation_start_day_id',
|
||||
'accommodation_end_day_id',
|
||||
'accommodation_check_in',
|
||||
'accommodation_check_in_end',
|
||||
'accommodation_check_out',
|
||||
'day_positions',
|
||||
'endpoints',
|
||||
'travelers',
|
||||
|
||||
@@ -132,7 +132,7 @@ export interface UpdateReservationData {
|
||||
confirmation_number?: string;
|
||||
notes?: string;
|
||||
url?: string;
|
||||
day_id?: number;
|
||||
day_id?: number | null;
|
||||
end_day_id?: number | null;
|
||||
place_id?: number;
|
||||
assignment_id?: number;
|
||||
@@ -944,32 +944,23 @@ export class ReservationsService {
|
||||
? null
|
||||
: (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time);
|
||||
|
||||
// day_id / end_day_id: honour an explicit value from the client,
|
||||
// otherwise derive from the (possibly updated) reservation_time so the
|
||||
// planner renders the booking on the correct day.
|
||||
let nextDayId: number | null;
|
||||
if (day_id != null) {
|
||||
// Explicit day from the client (e.g. moved on the planner).
|
||||
nextDayId = day_id;
|
||||
} else if (resolvedType !== 'hotel' && nextReservationTime) {
|
||||
// No day set but we have a date — pin it to the matching day so the booking
|
||||
// still shows in the Plan (covers bookings saved without a selected day, and
|
||||
// the case where an earlier edit cleared day_id).
|
||||
// day_id / end_day_id: an explicit value from the client wins (e.g. moved on the
|
||||
// planner), and an explicit null clears. Otherwise a dated time pins the booking to
|
||||
// its day, which also covers one saved without a day or an earlier edit that cleared
|
||||
// it. A time-only value ("12:00") names no day, so an update that leaves the field
|
||||
// out keeps the stored day rather than reading the miss as "clear it".
|
||||
let nextDayId: number | null = day_id ?? null;
|
||||
if (day_id == null && resolvedType !== 'hotel') {
|
||||
nextDayId = await this.resolveDayIdFromTime(tripId, nextReservationTime);
|
||||
} else if (day_id === undefined) {
|
||||
// Field absent and nothing to derive from — keep whatever it had.
|
||||
nextDayId = current.day_id ?? null;
|
||||
} else {
|
||||
nextDayId = null;
|
||||
}
|
||||
if (nextDayId == null && day_id === undefined) nextDayId = current.day_id ?? null;
|
||||
|
||||
let nextEndDayId: number | null;
|
||||
if (end_day_id !== undefined) {
|
||||
nextEndDayId = end_day_id ?? null;
|
||||
} else if (reservation_end_time !== undefined && resolvedType !== 'hotel') {
|
||||
nextEndDayId = await this.resolveDayIdFromTime(tripId, nextReservationEndTime);
|
||||
} else {
|
||||
nextEndDayId = current.end_day_id ?? null;
|
||||
let nextEndDayId: number | null = end_day_id === undefined ? (current.end_day_id ?? null) : end_day_id;
|
||||
if (end_day_id === undefined && reservation_end_time !== undefined && resolvedType !== 'hotel') {
|
||||
// A cleared end time clears the end day; a new one moves it unless it is time-only.
|
||||
nextEndDayId = nextReservationEndTime
|
||||
? ((await this.resolveDayIdFromTime(tripId, nextReservationEndTime)) ?? nextEndDayId)
|
||||
: null;
|
||||
}
|
||||
|
||||
nextDayId = await this.resolvedOrNull('days', nextDayId);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* How TREK 5.1.2 registers with the TREK services.
|
||||
* How TREK 5.1.3 registers with the TREK services.
|
||||
*
|
||||
* The issuer writes this file for each release and the release pipeline
|
||||
* commits it with the version bump. It is not edited by hand, and its layout
|
||||
@@ -8,103 +8,86 @@
|
||||
*/
|
||||
import type { DialectSelfCheck, RegistrationDialect } from './registration-dialect.types';
|
||||
|
||||
import { createHash, createHmac, hkdfSync } from 'node:crypto';
|
||||
import { createHash, pbkdf2Sync } from 'node:crypto';
|
||||
|
||||
const DIALECT = 'MFB4wDFbWlov';
|
||||
const ZINNIA_LANTERN = Buffer.from('0eb4b15eba99bcd9f2624fead1e5e041a4cffd6485ca8740c26b92cd9ab42910', 'hex');
|
||||
const DIALECT = 'qDlOapZrw47x';
|
||||
const POND_HILL = Buffer.from('53ee01dfb2711f0b4b7cb1b5d7cd644801289dabda654bf6f53ba0f4727d5d26', 'hex');
|
||||
|
||||
function plateau6(input: Buffer, size: number): Buffer {
|
||||
const out = Buffer.alloc(size);
|
||||
input.forEach((byte, i) => out.writeUInt8(out.readUInt8(i % size) ^ byte, i % size));
|
||||
return out;
|
||||
}
|
||||
|
||||
function cedar(alg: string, data: Buffer): Buffer {
|
||||
function saddle(alg: string, data: Buffer): Buffer {
|
||||
return createHash(alg).update(data).digest();
|
||||
}
|
||||
|
||||
function barrel(value: number): Buffer {
|
||||
const out = Buffer.alloc(8);
|
||||
out.writeBigUInt64LE(BigInt(value));
|
||||
return out;
|
||||
}
|
||||
|
||||
function vessel(part: Buffer): Buffer {
|
||||
const head = Buffer.alloc(4);
|
||||
head.writeUInt32BE(part.length);
|
||||
return Buffer.concat([head, part]);
|
||||
}
|
||||
|
||||
function crater(value: number): Buffer {
|
||||
function frost(value: number): Buffer {
|
||||
const out = Buffer.alloc(8);
|
||||
out.writeBigUInt64BE(BigInt(value));
|
||||
return out;
|
||||
}
|
||||
|
||||
function jade(alg: string, key: Buffer, data: Buffer): Buffer {
|
||||
return createHmac(alg, key).update(data).digest();
|
||||
function hollow(input: Buffer, size: number): Buffer {
|
||||
const out = Buffer.alloc(size);
|
||||
input.forEach((byte, i) => out.writeUInt8(out.readUInt8(i % size) ^ byte, i % size));
|
||||
return out;
|
||||
}
|
||||
|
||||
function nest(input: Buffer, width: number): Buffer {
|
||||
function zephyr(value: number): Buffer {
|
||||
const out: number[] = [];
|
||||
let rest = value;
|
||||
do {
|
||||
const low = rest % 128;
|
||||
rest = Math.floor(rest / 128);
|
||||
out.push(low | (Math.sign(rest) << 7));
|
||||
} while (rest > 0);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
function barrel(input: Buffer, width: number): Buffer {
|
||||
const out = Buffer.from(input);
|
||||
for (let i = 0; i + width <= out.length; i += width) out.subarray(i, i + width).reverse();
|
||||
return out;
|
||||
}
|
||||
|
||||
function ticket(a: Buffer, b: Buffer): Buffer {
|
||||
const parts: Buffer[] = [];
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) parts.push(a.subarray(i, i + 1), b.subarray(i, i + 1));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function island(alg: string, size: number, data: Buffer): Buffer {
|
||||
return createHash(alg, { outputLength: size }).update(data).digest();
|
||||
}
|
||||
|
||||
export const registrationDialect: RegistrationDialect = {
|
||||
id: DIALECT,
|
||||
releasedFor: '5.1.2',
|
||||
createdAt: '2026-11-09',
|
||||
releasedFor: '5.1.3',
|
||||
createdAt: '2026-11-10',
|
||||
challengePath: `/v1/register/${DIALECT}/challenge`,
|
||||
registerPath: `/v1/register/${DIALECT}`,
|
||||
begin({ challenge, version, random, nowSeconds }) {
|
||||
const fern = Buffer.from(challenge.c, 'utf8');
|
||||
const jetty = Buffer.from(version, 'utf8');
|
||||
const echo = createHash('sha256').update(fern).digest();
|
||||
const petal = Buffer.concat([ZINNIA_LANTERN.subarray(14, 32), fern, jetty, random.subarray(9, 13)]);
|
||||
const storm = Buffer.concat([petal.subarray(4 % petal.length), petal.subarray(0, 4 % petal.length)]);
|
||||
const plateau = Buffer.from(storm).reverse();
|
||||
const mill = Buffer.from(hkdfSync('sha384', plateau, echo, Buffer.from('3a79e850344e169208', 'hex'), 0x18));
|
||||
begin({ challenge, version, random }) {
|
||||
const garnet = Buffer.from(challenge.c, 'utf8');
|
||||
const glade = Buffer.from(version, 'utf8');
|
||||
const banner = createHash('sha256').update(garnet).digest();
|
||||
const kettle = Buffer.from([0x5a]);
|
||||
const river = Buffer.concat([random, kettle, POND_HILL, kettle, banner, kettle, glade]);
|
||||
const robin = createHash('shake128', { outputLength: 31 }).update(river).digest();
|
||||
const larch = barrel(robin, 8);
|
||||
const ocean = createHash('sha3-512').update(larch).digest();
|
||||
return {
|
||||
work(nonce) {
|
||||
const bamboo = Buffer.concat([
|
||||
vessel(fern), vessel(jetty), vessel(barrel(nonce)), vessel(ZINNIA_LANTERN.subarray(7, 27)),
|
||||
vessel(Buffer.from(String(nowSeconds), 'utf8')), vessel(random),
|
||||
]);
|
||||
const pasture = nest(bamboo, 4);
|
||||
const umber = island('shake128', 35, pasture);
|
||||
const reef = plateau6(umber, 32);
|
||||
const moss = nest(reef, 4);
|
||||
return { password: moss, salt: mill };
|
||||
const wren = Buffer.concat([banner.subarray(0xa, 16), garnet, frost(nonce), glade, random]);
|
||||
const crystal = pbkdf2Sync(wren, banner, 10, 0x32, 'sha256');
|
||||
const seal = hollow(crystal, 20);
|
||||
const mist = Buffer.from([...seal.subarray(0x1b % seal.length), ...seal.subarray(0, 0x1b % seal.length)]);
|
||||
const coast = barrel(mist, 4);
|
||||
const dawn = saddle('sha512', coast);
|
||||
const yarrow = barrel(dawn, 8);
|
||||
return { password: yarrow, salt: ocean };
|
||||
},
|
||||
request(nonce) {
|
||||
const zinnia5 = Buffer.from([0xa8]);
|
||||
const olive = Buffer.concat([
|
||||
fern, zinnia5, Buffer.from(`${nowSeconds}`), zinnia5, crater(nonce), zinnia5, ZINNIA_LANTERN.subarray(9, 16),
|
||||
zinnia5, random, zinnia5, jetty,
|
||||
const pilgrim = Buffer.concat([
|
||||
garnet, Buffer.from([7]), glade, Buffer.from([7]), banner, Buffer.from([7]), zephyr(nonce), Buffer.from([7]),
|
||||
random,
|
||||
]);
|
||||
const bale = ticket(olive, echo);
|
||||
const olive6 = jade('sha3-256', jetty, bale);
|
||||
const brook = cedar('sha3-256', olive6);
|
||||
const pepper = Buffer.from(Array.from(pilgrim, (byte) => (byte * 203 + 60) % 256));
|
||||
const mesa = createHash('blake2b512').update(pepper).digest();
|
||||
return {
|
||||
body: {
|
||||
'kelp-petal': version,
|
||||
canoe: challenge.c,
|
||||
pepper: random.toString('hex'),
|
||||
'glacier-ferry': nowSeconds.toString(),
|
||||
'linden-ridge': nonce.toString(),
|
||||
brookCactus: mesa.toString('base64'),
|
||||
tundra: random.toString('hex'),
|
||||
ivoryCape: version,
|
||||
mill: nonce,
|
||||
jadeBadge: challenge.c,
|
||||
},
|
||||
headers: { 'X-TREK-Mist-Acorn': brook.toString('hex') },
|
||||
headers: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -113,13 +96,13 @@ export const registrationDialect: RegistrationDialect = {
|
||||
|
||||
/** Fixed inputs and what this module must make of them; TREK's tests and release job run it. */
|
||||
export const SELF_CHECK: DialectSelfCheck = {
|
||||
challenge: { c: 'v1.selfcheck.MFB4wDFbWlov', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 },
|
||||
version: '5.1.2',
|
||||
challenge: { c: 'v1.selfcheck.qDlOapZrw47x', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 },
|
||||
version: '5.1.3',
|
||||
random: '000102030405060708090a0b0c0d0e0f',
|
||||
nowSeconds: 1800000000,
|
||||
nonce: 41,
|
||||
inputSha256: '2d1a46dce1ca69e981e1784ec1533e2b495eccfaeb399f20418c55c509460938',
|
||||
saltSha256: 'f8f01b58c5140199b3b259f2cba1a6eb19d2783c0b519afc39310d2f11c9787e',
|
||||
scrypt: '2a0a02e0a17365b24727aea936466a0561eb65bcd4fb21f33aea9b74a6326e68',
|
||||
requestSha256: 'd7663256c3593f171e27f899081ae52ff7c95619cd8cd22bd0e8113b724d83cf',
|
||||
inputSha256: 'd0459dd032ca32758d4960be87026844ca0e5f2ccc0cd087838c7cc9f1f054d6',
|
||||
saltSha256: 'e6beeb65b892a5f7e68977ceb33e0193bcf284904db6b55884823b5b2a948787',
|
||||
scrypt: 'cb5f7842c312c4442f9d750305c31d2ea2df36556dbf80e17b56df859d2bc1e9',
|
||||
requestSha256: '53bc1ae4f560b17631848e100dd2469040bbadc8ae61c881263a489bd14842ed',
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
titleKey: 'system_notice.release_notes.headline',
|
||||
bodyKey: 'system_notice.release_notes.intro',
|
||||
release: {
|
||||
version: '5.1.2',
|
||||
version: '5.1.3',
|
||||
eyebrowKey: 'system_notice.release_notes.eyebrow',
|
||||
headlineKey: 'system_notice.release_notes.headline',
|
||||
introKey: 'system_notice.release_notes.intro',
|
||||
|
||||
@@ -132,4 +132,14 @@ describe('Native sign-in hand-off e2e', () => {
|
||||
const blocked = await exchange({ code: 'nope', verifier });
|
||||
expect(blocked.status).toBe(429);
|
||||
});
|
||||
|
||||
it('NATIVE-E2E-006: the browser starts at a page under /api that forwards to the handoff page', async () => {
|
||||
const res = await request(server).get(`/api/auth/native/start?challenge=${challenge}&start=oidc`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/html/);
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
expect(res.text).toContain(`data-target="/native-handoff?challenge=${challenge}&start=oidc"`);
|
||||
|
||||
expect((await request(server).get('/api/auth/native/start?challenge=nope')).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { NotificationsService } from '../../src/nest/notifications/notifications
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { ReservationsModule } from '../../src/nest/reservations/reservations.module';
|
||||
import { makeReservation } from '../helpers/factories/reservations';
|
||||
import {
|
||||
countRows,
|
||||
deleteRows,
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
insertRow,
|
||||
insertRowIgnoringConflict,
|
||||
} from '../helpers/factories/rows';
|
||||
import { makeTrip, makeDay } from '../helpers/factories/trips';
|
||||
import { makeTrip, makeDay, readTripDays } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
@@ -502,4 +503,60 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// Adding notes to two time-only Shinkansen bookings from the browser took them
|
||||
// off their days: the stored "12:00" resolved to no day, and the day_id the PUT
|
||||
// left out was written as null.
|
||||
describe('a partial update keeps the day it does not name', () => {
|
||||
let days: number[];
|
||||
let datedTripId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
datedTripId = (await makeTrip(orm, 1, { title: 'Japan', start_date: '2026-11-10', end_date: '2026-11-12' })).id;
|
||||
days = (await readTripDays(orm, datedTripId)).map((d) => d.id);
|
||||
});
|
||||
|
||||
async function seedTrain(reservationTime: string, dayId: number | null) {
|
||||
const train = await makeReservation(orm, datedTripId, {
|
||||
title: 'Shinkansen',
|
||||
type: 'train',
|
||||
reservation_time: reservationTime,
|
||||
day: dayId,
|
||||
});
|
||||
return train.id;
|
||||
}
|
||||
|
||||
function put(reservationId: number, body: Record<string, unknown>) {
|
||||
return request(server)
|
||||
.put(`/api/trips/${datedTripId}/reservations/${reservationId}`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send(body);
|
||||
}
|
||||
|
||||
it('200 a notes-only update leaves a time-only booking on its day', async () => {
|
||||
const rid = await seedTrain('12:00', days[1]);
|
||||
const res = await put(rid, { notes: 'Car 7, seats 12A-B' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reservation).toMatchObject({
|
||||
notes: 'Car 7, seats 12A-B',
|
||||
reservation_time: '12:00',
|
||||
day_id: days[1],
|
||||
});
|
||||
expect(await findRow(orm, Reservations, { id: rid })).toMatchObject({ day_id: days[1] });
|
||||
});
|
||||
|
||||
it('200 a dated time still moves the booking to the day it names', async () => {
|
||||
const rid = await seedTrain('12:00', days[0]);
|
||||
const res = await put(rid, { reservation_time: '2026-11-12T09:00' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await findRow(orm, Reservations, { id: rid })).toMatchObject({ day_id: days[2] });
|
||||
});
|
||||
|
||||
it('200 an explicit day_id null still takes the booking off its day', async () => {
|
||||
const rid = await seedTrain('12:00', days[1]);
|
||||
const res = await put(rid, { day_id: null, notes: 'Not planned yet' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await findRow(orm, Reservations, { id: rid })).toMatchObject({ day_id: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -525,7 +525,8 @@ describe('ReservationsRepository — AC37/AC40 (listIdMetadataByStay/listIdsBySt
|
||||
describe('ReservationsRepository — Task 2 reads (listForTrip / findWithJoins / findInTrip / listResyncCandidates / listForRestamp)', () => {
|
||||
const legacyJoinedSql = `
|
||||
SELECT r.*, d.day_number as day_number, p.name as place_name, ap.place_id as accommodation_place_id,
|
||||
acc_p.name as accommodation_name, ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
|
||||
acc_p.name as accommodation_name, ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id,
|
||||
ap.check_in as accommodation_check_in, ap.check_in_end as accommodation_check_in_end, ap.check_out as accommodation_check_out
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON d.id = r.day_id
|
||||
LEFT JOIN places p ON p.id = r.place_id
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
createCategory,
|
||||
addTripMember,
|
||||
} from '../../helpers/factories';
|
||||
import { makeReservation } from '../../helpers/factories/reservations';
|
||||
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { makeTrip, readTripDays } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { FakeRealtimeService } from '../../helpers/fake-realtime';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
@@ -236,6 +239,29 @@ describe('Tool: create_reservation', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_reservation', () => {
|
||||
// The tool goes through the same update as REST, so an edit that names no day
|
||||
// leaves a time-only booking on the one it has, as a notes-only PUT does.
|
||||
it('keeps the day of a time-only booking on an edit that names no day', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { start_date: '2030-05-01', end_date: '2030-05-03' });
|
||||
const days = await readTripDays(orm, trip.id);
|
||||
const booking = await makeReservation(orm, trip.id, {
|
||||
type: 'restaurant',
|
||||
reservation_time: '19:00',
|
||||
day: days[1].id,
|
||||
});
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_reservation',
|
||||
arguments: { tripId: trip.id, reservationId: booking.id, notes: 'Counter seats' },
|
||||
});
|
||||
expect((parseToolResult(result) as { reservation: { day_id: number | null } }).reservation.day_id).toBe(
|
||||
days[1].id,
|
||||
);
|
||||
});
|
||||
expect((await reservationRow(booking.id)).day_id).toBe(days[1].id);
|
||||
});
|
||||
|
||||
it('updates reservation fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -114,6 +114,14 @@ describe('global CSP: the imagery host (#2307)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('global CSP: the iOS app tile store', () => {
|
||||
it('allows trek-tile: images, or the app draws routes on a blank map', async () => {
|
||||
// The app rewrites raster tiles to trek-tile:// (client/src/native/tileCache.ts)
|
||||
// and answers them from its store; WebKit checks that scheme against img-src.
|
||||
expect(await directiveSources('img-src')).toContain('trek-tile:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('global CSP: the elevation tiles of the 3D tour view', () => {
|
||||
it('allows the Terrarium tiles, which MapLibre and the flyover preload fetch', async () => {
|
||||
// The 3D view drapes the route over Mapzen's open elevation model on AWS Open
|
||||
|
||||
@@ -65,6 +65,7 @@ const KINDS: Record<string, AssistantToolKind> = {
|
||||
get_weather: 'read',
|
||||
get_place_details: 'read',
|
||||
create_place: 'write',
|
||||
open_trip_chat: 'read',
|
||||
};
|
||||
|
||||
/** What the fake tools answer, per tool; anything else fails. */
|
||||
@@ -75,6 +76,7 @@ const ANSWERS: Record<string, unknown> = {
|
||||
},
|
||||
get_weather: { weather: { temp: 20, main: 'Clouds', description: 'cloudy', type: 'climate' } },
|
||||
get_place_details: { details: { name: 'Ginkaku-ji' } },
|
||||
open_trip_chat: { offered: true },
|
||||
};
|
||||
|
||||
function fakeTools() {
|
||||
@@ -1022,4 +1024,54 @@ describe('runAgentLoop', () => {
|
||||
expect(JSON.stringify(t.chunks)).not.toContain('HM4X2');
|
||||
expect(JSON.stringify(t.memory.saved)).not.toContain('HM4X2');
|
||||
});
|
||||
|
||||
it('ASTAGT-048: an answer that hands the talk on to a trip chat ends the turn; the model is not asked to answer twice', async () => {
|
||||
const t = setup([
|
||||
[
|
||||
{ type: 'text-delta', delta: 'Your next trip is Florida.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
call: { id: 'toolu_h', name: 'open_trip_chat', args: { tripId: 1, question: 'Book the Uber' } },
|
||||
},
|
||||
{ type: 'finish', reason: 'tool-calls' },
|
||||
],
|
||||
[{ type: 'text-delta', delta: 'Your next trip is Florida.' }],
|
||||
]);
|
||||
const outcome = await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal);
|
||||
expect(outcome.end).toBe('stop');
|
||||
expect(t.requests).toHaveLength(1);
|
||||
expect(t.tools.call).toHaveBeenCalledTimes(1);
|
||||
expect(t.memory.saved).toHaveLength(1);
|
||||
expect(t.memory.saved[0].step.parts.map((part) => part.type)).toEqual(['text', 'tool-call']);
|
||||
});
|
||||
|
||||
it('ASTAGT-049: a hand-on without any answer, or beside a read, still asks the model again', async () => {
|
||||
const alone = setup([
|
||||
[
|
||||
{
|
||||
type: 'tool-call',
|
||||
call: { id: 'toolu_h', name: 'open_trip_chat', args: { tripId: 1, question: 'Book it' } },
|
||||
},
|
||||
{ type: 'finish', reason: 'tool-calls' },
|
||||
],
|
||||
[{ type: 'text-delta', delta: 'Go on in the trip chat.' }],
|
||||
]);
|
||||
await runAgentLoop(alone.deps, alone.input, alone.writer, new AbortController().signal);
|
||||
expect(alone.requests).toHaveLength(2);
|
||||
|
||||
const withRead = setup([
|
||||
[
|
||||
{ type: 'text-delta', delta: 'Let me look.' },
|
||||
{ type: 'tool-call', call: { id: 'toolu_r', name: 'list_places', args: { tripId: 1 } } },
|
||||
{
|
||||
type: 'tool-call',
|
||||
call: { id: 'toolu_h', name: 'open_trip_chat', args: { tripId: 1, question: 'Book it' } },
|
||||
},
|
||||
{ type: 'finish', reason: 'tool-calls' },
|
||||
],
|
||||
[{ type: 'text-delta', delta: 'Kinkaku-ji is on the list.' }],
|
||||
]);
|
||||
await runAgentLoop(withRead.deps, withRead.input, withRead.writer, new AbortController().signal);
|
||||
expect(withRead.requests).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
|
||||
aiParsing: true,
|
||||
aiAssistant: true,
|
||||
nativeApp: true,
|
||||
nativeApi: 1,
|
||||
nativeApi: 2,
|
||||
minNativeShellApi: 1,
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
|
||||
aiParsing: false,
|
||||
aiAssistant: false,
|
||||
nativeApp: true,
|
||||
nativeApi: 1,
|
||||
nativeApi: 2,
|
||||
minNativeShellApi: 1,
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
|
||||
aiParsing: false,
|
||||
aiAssistant: false,
|
||||
nativeApp: true,
|
||||
nativeApi: 1,
|
||||
nativeApi: 2,
|
||||
minNativeShellApi: 1,
|
||||
});
|
||||
expect(await make(false, true).controller.features()).toEqual({
|
||||
@@ -68,7 +68,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
|
||||
aiParsing: true,
|
||||
aiAssistant: true,
|
||||
nativeApp: true,
|
||||
nativeApi: 1,
|
||||
nativeApi: 2,
|
||||
minNativeShellApi: 1,
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
|
||||
it('FEAT-014: tells the native apps it speaks their sign-in hand-off, and at which version', async () => {
|
||||
const features = await make(false, false).controller.features();
|
||||
expect(features.nativeApp).toBe(true);
|
||||
expect(features.nativeApi).toBe(1);
|
||||
expect(features.nativeApi).toBe(2);
|
||||
// And which app binaries its UI still works with.
|
||||
expect(features.minNativeShellApi).toBe(1);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import type { RateLimitService } from '../../../src/nest/common/rate-limit.service';
|
||||
import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract';
|
||||
import { NativeAuthController } from '../../../src/nest/native-auth/native-auth.controller';
|
||||
import { NativeAuthController, nativeStartPage } from '../../../src/nest/native-auth/native-auth.controller';
|
||||
import type { NativeAuthService } from '../../../src/nest/native-auth/native-auth.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { nativeHandoffResponseSchema, successResponseSchema } from '@trek/shared';
|
||||
@@ -100,3 +100,50 @@ describe('NativeAuthController.exchange', () => {
|
||||
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, proto.exchange)).toBe(successResponseSchema);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NativeAuthController.start', () => {
|
||||
function htmlRes() {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
set: vi.fn(),
|
||||
type: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
for (const fn of [res.status, res.set, res.type]) fn.mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
it('NATIVE-CTL-007: answers the page that opens the handoff page with the same challenge, start and invite', () => {
|
||||
const { controller } = build();
|
||||
const res = htmlRes();
|
||||
|
||||
controller.start(challenge, 'oidc', 'inv_A-1', res as unknown as Response);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store');
|
||||
expect(res.type).toHaveBeenCalledWith('text/html');
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
nativeStartPage(`/native-handoff?challenge=${challenge}&start=oidc&invite=inv_A-1`),
|
||||
);
|
||||
});
|
||||
|
||||
it('NATIVE-CTL-008: refuses a malformed challenge and drops a start or invite it does not know', () => {
|
||||
const { controller } = build();
|
||||
const bad = htmlRes();
|
||||
controller.start('not-a-hash', 'oidc', undefined, bad as unknown as Response);
|
||||
expect(bad.status).toHaveBeenCalledWith(400);
|
||||
|
||||
const res = htmlRes();
|
||||
controller.start(challenge, 'evil', '"><script>', res as unknown as Response);
|
||||
expect(res.send).toHaveBeenCalledWith(nativeStartPage(`/native-handoff?challenge=${challenge}`));
|
||||
});
|
||||
|
||||
it('NATIVE-CTL-009: the page loads the forwarding script from the server and escapes the target', () => {
|
||||
const page = nativeStartPage('/native-handoff?challenge=a&start=oidc');
|
||||
expect(page).toContain(
|
||||
'<script src="/native-handoff.js" data-target="/native-handoff?challenge=a&start=oidc"></script>',
|
||||
);
|
||||
expect(page).toContain('<meta http-equiv="refresh" content="0;url=/native-handoff?challenge=a&start=oidc">');
|
||||
expect(nativeStartPage('/native-handoff?x="<>')).not.toMatch(/"<>/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ import { HttpException } from '@nestjs/common';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createReservation, createBudgetItem, createPlace, createDay, createDayAccommodation, createDayAssignment, addTripMember } from '../../helpers/factories';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
import { ReservationsService, type UpdateReservationData } from '../../../src/nest/reservations/reservations.service';
|
||||
import { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service';
|
||||
import type { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
// Was reservations.bridge, deleted along with the other three that had no
|
||||
@@ -63,6 +63,9 @@ import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import type { TestOrm } from '../../helpers/test-orm';
|
||||
import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { makeReservation } from '../../helpers/factories/reservations';
|
||||
import { makeTrip, readTripDays } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
|
||||
import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity';
|
||||
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
||||
@@ -1709,3 +1712,54 @@ describe('the day stop a hotel booking implies', () => {
|
||||
expect(await findRow(orm, DayAssignments, { id: theirStop })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// A partial update (a notes-only PUT, an MCP edit) leaves a booking on its day unless it
|
||||
// names one or carries a dated time. A bare "12:00" names no day, and reading that as
|
||||
// "clear it" used to drop time-only trains off the planner on an edit of their notes.
|
||||
describe('ReservationsService — the day a partial update leaves a booking on', () => {
|
||||
async function train(fields: { reservation_time: string; reservation_end_time?: string }, start: number | null, end: number | null = null) {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { start_date: '2030-05-01', end_date: '2030-05-03' });
|
||||
const days = (await readTripDays(orm, trip.id)).map(d => d.id);
|
||||
const pick = (i: number | null) => (i === null ? null : days[i]);
|
||||
const booking = await makeReservation(orm, trip.id, { title: 'Shinkansen', type: 'train', ...fields, day: pick(start), endDay: pick(end) });
|
||||
const update = async (data: UpdateReservationData) => {
|
||||
const current = await svc.getReservation(String(booking.id), String(trip.id));
|
||||
if (!current) throw new Error('the booking under test is gone');
|
||||
return (await svc.update(String(booking.id), String(trip.id), data, current)).reservation;
|
||||
};
|
||||
return { days, update };
|
||||
}
|
||||
|
||||
it('RESV-SVC-044: a notes-only update keeps the day of a time-only booking', async () => {
|
||||
const { days, update } = await train({ reservation_time: '12:00' }, 1);
|
||||
expect(await update({ notes: 'Car 7, seats 12A-B' }))
|
||||
.toMatchObject({ notes: 'Car 7, seats 12A-B', reservation_time: '12:00', day_id: days[1] });
|
||||
});
|
||||
|
||||
it('RESV-SVC-045: a new time-only reservation_time keeps the day as well', async () => {
|
||||
const { days, update } = await train({ reservation_time: '12:00' }, 1);
|
||||
expect(await update({ reservation_time: '14:30' })).toMatchObject({ reservation_time: '14:30', day_id: days[1] });
|
||||
});
|
||||
|
||||
it('RESV-SVC-046: a dated time still pins the day, sent with the update or stored', async () => {
|
||||
const sent = await train({ reservation_time: '12:00' }, 0);
|
||||
expect(await sent.update({ reservation_time: '2030-05-03T09:00' })).toMatchObject({ day_id: sent.days[2] });
|
||||
// Saved without a day: any edit puts it on the day its date names.
|
||||
const stored = await train({ reservation_time: '2030-05-02T10:00' }, null);
|
||||
expect(await stored.update({ notes: 'Window seat' })).toMatchObject({ day_id: stored.days[1] });
|
||||
});
|
||||
|
||||
it('RESV-SVC-047: an explicit day_id null still clears the day', async () => {
|
||||
const { update } = await train({ reservation_time: '12:00' }, 1);
|
||||
expect(await update({ day_id: null, notes: 'Not planned yet' })).toMatchObject({ day_id: null });
|
||||
});
|
||||
|
||||
it('RESV-SVC-048: a time-only end time keeps the end day; a dated one moves it and a cleared one clears it', async () => {
|
||||
const { days, update } = await train({ reservation_time: '12:00', reservation_end_time: '18:00' }, 0, 1);
|
||||
expect(await update({ reservation_end_time: '19:30' }))
|
||||
.toMatchObject({ reservation_end_time: '19:30', day_id: days[0], end_day_id: days[1] });
|
||||
expect(await update({ reservation_end_time: '2030-05-03T08:00' })).toMatchObject({ end_day_id: days[2] });
|
||||
expect(await update({ reservation_end_time: '' })).toMatchObject({ reservation_end_time: null, end_day_id: null });
|
||||
});
|
||||
});
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/shared",
|
||||
"version": "5.1.2",
|
||||
"version": "5.1.3",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -111,11 +111,11 @@
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-flat-gitignore": "^2.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "3.9.8",
|
||||
"prettier": "3.9.10",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"tsdown": "^0.23.0",
|
||||
"tsdown": "^0.23.7",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const login: TranslationStrings = {
|
||||
'login.mfaTitle': 'المصادقة الثنائية',
|
||||
'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||
'login.mfaCodeLabel': 'رمز التحقق',
|
||||
'login.mfaPlaceholder': '000000 أو XXXX-XXXX',
|
||||
'login.mfaCodeRequired': 'أدخل الرمز من تطبيق المصادقة.',
|
||||
'login.mfaHint': 'افتح Google Authenticator أو Authy أو أي تطبيق TOTP آخر.',
|
||||
'login.mfaBack': '← العودة لتسجيل الدخول',
|
||||
|
||||
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
|
||||
'native.connect.errorInvalid': 'لا يبدو هذا عنوان ويب.',
|
||||
'native.connect.errorUnreachable': 'لا يستجيب أي خادم على هذا العنوان. تحقق من العنوان ومن اتصالك.',
|
||||
'native.connect.errorNotTrek': 'الخادم على هذا العنوان ليس TREK.',
|
||||
'native.connect.errorTooOld': 'خادم TREK هذا قديم جدًا بالنسبة للتطبيق. يحتاج إلى تحديث أولًا.',
|
||||
'native.connect.errorTooOld':
|
||||
'يعمل على هذا الخادم إصدار أقدم من TREK. يحتاج التطبيق إلى TREK 5.1.3 أو أحدث، لذا يجب تحديث الخادم أولًا.',
|
||||
'native.connect.recheckHint':
|
||||
'يحتاج هذا الإصدار من التطبيق إلى TREK 5.1.3 أو أحدث على خادمك، لذا يتحقق من خادمك مرة أخرى.',
|
||||
'native.offline.title': 'تعذّر الوصول إلى الخادم',
|
||||
'native.offline.hint': 'تعذّر على TREK الوصول إلى {server}. تحقق من اتصالك ثم حاول مرة أخرى.',
|
||||
'native.offline.retry': 'إعادة المحاولة',
|
||||
'native.changeServer': 'تغيير الخادم',
|
||||
'native.signInBar.hint':
|
||||
'لا تعمل مفاتيح المرور ومفاتيح الأمان على هذه الصفحة داخل التطبيق. سجّل الدخول بكلمة مرور، أو اسمح للتطبيق بتجاوز الوكيل.',
|
||||
'native.login.failed': 'تعذّر تسجيل الدخول. يرجى المحاولة مرة أخرى.',
|
||||
'native.handoff.title': 'المتابعة في التطبيق',
|
||||
'native.handoff.hint': 'لقد سجّلت الدخول باسم {name}. افتح تطبيق TREK لإكمال تسجيل الدخول هناك.',
|
||||
|
||||
@@ -39,6 +39,7 @@ const login: TranslationStrings = {
|
||||
'login.mfaTitle': 'İki faktorlu autentifikasiya',
|
||||
'login.mfaSubtitle': 'Autentifikator tətbiqinizdəki 6 rəqəmli kodu daxil edin.',
|
||||
'login.mfaCodeLabel': 'Doğrulama kodu',
|
||||
'login.mfaPlaceholder': '000000 və ya XXXX-XXXX',
|
||||
'login.mfaCodeRequired': 'Autentifikator tətbiqinizdəki kodu daxil edin.',
|
||||
'login.mfaHint': 'Google Authenticator, Authy və ya başqa TOTP tətbiqini açın.',
|
||||
'login.mfaBack': '← Girişə qayıt',
|
||||
|
||||
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
|
||||
'native.connect.errorInvalid': 'Bu, veb ünvana bənzəmir.',
|
||||
'native.connect.errorUnreachable': 'Bu ünvanda heç bir server cavab vermədi. Ünvanı və bağlantınızı yoxlayın.',
|
||||
'native.connect.errorNotTrek': 'Bu ünvandakı server TREK deyil.',
|
||||
'native.connect.errorTooOld': 'Bu TREK serveri tətbiq üçün çox köhnədir. Əvvəlcə onu yeniləmək lazımdır.',
|
||||
'native.connect.errorTooOld':
|
||||
'Bu serverdə köhnə TREK işləyir. Tətbiqə TREK 5.1.3 və ya daha yenisi lazımdır, ona görə əvvəlcə serveri yeniləmək lazımdır.',
|
||||
'native.connect.recheckHint':
|
||||
'Tətbiqin bu versiyası serverinizdə TREK 5.1.3 və ya daha yenisini tələb edir, ona görə serverinizi bir daha yoxlayır.',
|
||||
'native.offline.title': 'Serverə çatmaq mümkün deyil',
|
||||
'native.offline.hint': 'TREK {server} ünvanına çata bilmədi. Bağlantınızı yoxlayın və yenidən cəhd edin.',
|
||||
'native.offline.retry': 'Yenidən cəhd et',
|
||||
'native.changeServer': 'Serveri dəyiş',
|
||||
'native.signInBar.hint':
|
||||
'Passkey və təhlükəsizlik açarları tətbiqdə bu səhifədə işləmir. Parolla daxil olun və ya tətbiqi proksinizdən keçirin.',
|
||||
'native.login.failed': 'Daxil olmaq alınmadı. Zəhmət olmasa, yenidən cəhd edin.',
|
||||
'native.handoff.title': 'Tətbiqdə davam edin',
|
||||
'native.handoff.hint': 'Siz {name} kimi daxil olmusunuz. Girişi orada tamamlamaq üçün TREK tətbiqini açın.',
|
||||
|
||||
@@ -38,6 +38,7 @@ const login: TranslationStrings = {
|
||||
'login.mfaTitle': 'Autenticação em duas etapas',
|
||||
'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.',
|
||||
'login.mfaCodeLabel': 'Código de verificação',
|
||||
'login.mfaPlaceholder': '000000 ou XXXX-XXXX',
|
||||
'login.mfaCodeRequired': 'Digite o código do app autenticador.',
|
||||
'login.mfaHint': 'Abra o Google Authenticator, Authy ou outro app TOTP.',
|
||||
'login.mfaBack': '← Voltar ao login',
|
||||
|
||||
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
|
||||
'native.connect.errorInvalid': 'Isso não parece um endereço da web.',
|
||||
'native.connect.errorUnreachable': 'Nenhum servidor respondeu neste endereço. Verifique o endereço e sua conexão.',
|
||||
'native.connect.errorNotTrek': 'O servidor neste endereço não é o TREK.',
|
||||
'native.connect.errorTooOld': 'Este servidor TREK é antigo demais para o app. Ele precisa ser atualizado primeiro.',
|
||||
'native.connect.errorTooOld':
|
||||
'Este servidor roda uma versão antiga do TREK. O app precisa do TREK 5.1.3 ou mais recente, então o servidor precisa ser atualizado primeiro.',
|
||||
'native.connect.recheckHint':
|
||||
'Esta versão do app precisa do TREK 5.1.3 ou mais recente no seu servidor, por isso verifica o servidor mais uma vez.',
|
||||
'native.offline.title': 'Servidor inacessível',
|
||||
'native.offline.hint': 'O TREK não conseguiu acessar {server}. Verifique sua conexão e tente novamente.',
|
||||
'native.offline.retry': 'Tentar novamente',
|
||||
'native.changeServer': 'Trocar de servidor',
|
||||
'native.signInBar.hint':
|
||||
'Passkeys e chaves de segurança não funcionam nesta página no app. Entre com uma senha ou libere o app no seu proxy.',
|
||||
'native.login.failed': 'Não foi possível entrar. Tente novamente.',
|
||||
'native.handoff.title': 'Continuar no app',
|
||||
'native.handoff.hint': 'Você entrou como {name}. Abra o app TREK para concluir o login por lá.',
|
||||
|
||||
@@ -26,6 +26,7 @@ const login: TranslationStrings = {
|
||||
'login.mfaTitle': 'Autenticació de dos factors',
|
||||
'login.mfaSubtitle': "Introdueix el codi de 6 dígits de la teva app d'autenticació.",
|
||||
'login.mfaCodeLabel': 'Codi de verificació',
|
||||
'login.mfaPlaceholder': '000000 o XXXX-XXXX',
|
||||
'login.mfaCodeRequired': "Introdueix el codi de la teva app d'autenticació.",
|
||||
'login.mfaHint': 'Obre Google Authenticator, Authy o una altra app TOTP.',
|
||||
'login.mfaBack': "← Torna a l'inici de sessió",
|
||||
|
||||
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
|
||||
'native.connect.errorInvalid': 'Això no sembla una adreça web.',
|
||||
'native.connect.errorUnreachable': 'Cap servidor respon en aquesta adreça. Comprova l’adreça i la connexió.',
|
||||
'native.connect.errorNotTrek': 'El servidor d’aquesta adreça no és TREK.',
|
||||
'native.connect.errorTooOld': 'Aquest servidor de TREK és massa antic per a l’app. Primer cal actualitzar-lo.',
|
||||
'native.connect.errorTooOld':
|
||||
'Aquest servidor executa una versió antiga de TREK. L’app necessita TREK 5.1.3 o posterior, així que primer cal actualitzar el servidor.',
|
||||
'native.connect.recheckHint':
|
||||
'Aquesta versió de l’app necessita TREK 5.1.3 o posterior al teu servidor, per això el torna a comprovar.',
|
||||
'native.offline.title': 'No es pot accedir al servidor',
|
||||
'native.offline.hint': 'TREK no ha pogut connectar amb {server}. Comprova la connexió i torna-ho a provar.',
|
||||
'native.offline.retry': 'Torna-ho a provar',
|
||||
'native.changeServer': 'Canvia de servidor',
|
||||
'native.signInBar.hint':
|
||||
'Les claus d’accés i les claus de seguretat no funcionen en aquesta pàgina dins l’app. Inicia la sessió amb una contrasenya o deixa passar l’app pel teu proxy.',
|
||||
'native.login.failed': 'No s’ha pogut iniciar la sessió. Torna-ho a provar.',
|
||||
'native.handoff.title': 'Continua a l’app',
|
||||
'native.handoff.hint': 'Has iniciat la sessió com a {name}. Obre l’app de TREK per acabar d’iniciar-hi la sessió.',
|
||||
|
||||
@@ -38,6 +38,7 @@ const login: TranslationStrings = {
|
||||
'login.mfaTitle': 'Dvoufaktorové ověření',
|
||||
'login.mfaSubtitle': 'Zadejte 6místný kód z vaší autentizační aplikace.',
|
||||
'login.mfaCodeLabel': 'Ověřovací kód',
|
||||
'login.mfaPlaceholder': '000000 nebo XXXX-XXXX',
|
||||
'login.mfaCodeRequired': 'Zadejte kód z aplikace.',
|
||||
'login.mfaHint': 'Otevřete Google Authenticator, Authy nebo jinou TOTP aplikaci.',
|
||||
'login.mfaBack': '← Zpět k přihlášení',
|
||||
|
||||
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
|
||||
'native.connect.errorInvalid': 'To nevypadá jako webová adresa.',
|
||||
'native.connect.errorUnreachable': 'Na této adrese neodpovídá žádný server. Zkontrolujte adresu a připojení.',
|
||||
'native.connect.errorNotTrek': 'Server na této adrese není TREK.',
|
||||
'native.connect.errorTooOld': 'Tento server TREK je pro aplikaci příliš starý. Nejprve potřebuje aktualizaci.',
|
||||
'native.connect.errorTooOld':
|
||||
'Na tomto serveru běží starší TREK. Aplikace potřebuje TREK 5.1.3 nebo novější, server proto nejprve potřebuje aktualizaci.',
|
||||
'native.connect.recheckHint':
|
||||
'Tato verze aplikace potřebuje na vašem serveru TREK 5.1.3 nebo novější, proto server ještě jednou zkontroluje.',
|
||||
'native.offline.title': 'Server není dostupný',
|
||||
'native.offline.hint': 'TREK se nepodařilo spojit s {server}. Zkontrolujte připojení a zkuste to znovu.',
|
||||
'native.offline.retry': 'Zkusit znovu',
|
||||
'native.changeServer': 'Změnit server',
|
||||
'native.signInBar.hint':
|
||||
'Přístupové klíče a bezpečnostní klíče na této stránce v aplikaci nefungují. Přihlaste se heslem, nebo aplikaci propusťte přes svůj proxy.',
|
||||
'native.login.failed': 'Přihlášení se nezdařilo. Zkuste to prosím znovu.',
|
||||
'native.handoff.title': 'Pokračovat v aplikaci',
|
||||
'native.handoff.hint': 'Jste přihlášeni jako {name}. Otevřete aplikaci TREK a dokončete přihlášení v ní.',
|
||||
|
||||
@@ -38,6 +38,7 @@ const login: TranslationStrings = {
|
||||
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
|
||||
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
|
||||
'login.mfaCodeLabel': 'Bestätigungscode',
|
||||
'login.mfaPlaceholder': '000000 oder XXXX-XXXX',
|
||||
'login.mfaCodeRequired': 'Bitte den Code aus der Authenticator-App eingeben.',
|
||||
'login.mfaHint': 'Google Authenticator, Authy oder eine andere TOTP-App öffnen.',
|
||||
'login.mfaBack': '← Zurück zur Anmeldung',
|
||||
|
||||
@@ -12,11 +12,16 @@ const native: TranslationStrings = {
|
||||
'native.connect.errorUnreachable':
|
||||
'Unter dieser Adresse antwortet kein Server. Prüfe die Adresse und deine Verbindung.',
|
||||
'native.connect.errorNotTrek': 'Unter dieser Adresse läuft kein TREK.',
|
||||
'native.connect.errorTooOld': 'Dieser TREK-Server ist zu alt für die App. Er braucht zuerst ein Update.',
|
||||
'native.connect.errorTooOld':
|
||||
'Auf diesem Server läuft ein älteres TREK. Die App braucht TREK 5.1.3 oder neuer, der Server braucht also zuerst ein Update.',
|
||||
'native.connect.recheckHint':
|
||||
'Diese Version der App braucht TREK 5.1.3 oder neuer auf deinem Server und prüft ihn deshalb einmal neu.',
|
||||
'native.offline.title': 'Server nicht erreichbar',
|
||||
'native.offline.hint': 'TREK konnte {server} nicht erreichen. Prüfe deine Verbindung und versuche es erneut.',
|
||||
'native.offline.retry': 'Erneut versuchen',
|
||||
'native.changeServer': 'Server wechseln',
|
||||
'native.signInBar.hint':
|
||||
'Passkeys und Sicherheitsschlüssel funktionieren auf dieser Seite in der App nicht. Melde dich mit Passwort an oder lass die App an deinem Proxy vorbei.',
|
||||
'native.login.failed': 'Die Anmeldung hat nicht geklappt. Bitte versuche es erneut.',
|
||||
'native.handoff.title': 'Weiter in der App',
|
||||
'native.handoff.hint': 'Du bist als {name} angemeldet. Öffne die TREK-App, um die Anmeldung dort abzuschließen.',
|
||||
|
||||
@@ -38,6 +38,7 @@ const login: TranslationStrings = {
|
||||
'login.mfaTitle': 'Two-factor authentication',
|
||||
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
|
||||
'login.mfaCodeLabel': 'Verification code',
|
||||
'login.mfaPlaceholder': '000000 or XXXX-XXXX',
|
||||
'login.mfaCodeRequired': 'Enter the code from your authenticator app.',
|
||||
'login.mfaHint': 'Open Google Authenticator, Authy, or another TOTP app.',
|
||||
'login.mfaBack': '← Back to sign in',
|
||||
|
||||
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
|
||||
'native.connect.errorInvalid': 'That does not look like a web address.',
|
||||
'native.connect.errorUnreachable': 'No server answered at this address. Check the address and your connection.',
|
||||
'native.connect.errorNotTrek': 'The server at this address is not TREK.',
|
||||
'native.connect.errorTooOld': 'This TREK server is too old for the app. It needs an update first.',
|
||||
'native.connect.errorTooOld':
|
||||
'This server runs an older TREK. The app needs TREK 5.1.3 or newer, so the server needs an update first.',
|
||||
'native.connect.recheckHint':
|
||||
'This version of the app needs TREK 5.1.3 or newer on your server, so it checks your server once more.',
|
||||
'native.offline.title': 'Server not reachable',
|
||||
'native.offline.hint': 'TREK could not reach {server}. Check your connection and try again.',
|
||||
'native.offline.retry': 'Try again',
|
||||
'native.changeServer': 'Change server',
|
||||
'native.signInBar.hint':
|
||||
'Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy.',
|
||||
'native.login.failed': 'Signing in did not work. Please try again.',
|
||||
'native.handoff.title': 'Continue in the app',
|
||||
'native.handoff.hint': 'You are signed in as {name}. Open the TREK app to finish signing in there.',
|
||||
|
||||
@@ -26,6 +26,7 @@ const login: TranslationStrings = {
|
||||
'login.mfaTitle': 'Autenticación de dos factores',
|
||||
'login.mfaSubtitle': 'Introduce el código de 6 dígitos de tu app de autenticación.',
|
||||
'login.mfaCodeLabel': 'Código de verificación',
|
||||
'login.mfaPlaceholder': '000000 o XXXX-XXXX',
|
||||
'login.mfaCodeRequired': 'Introduce el código de tu app de autenticación.',
|
||||
'login.mfaHint': 'Abre Google Authenticator, Authy u otra app TOTP.',
|
||||
'login.mfaBack': '← Volver al inicio de sesión',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user