mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-10 06:11:02 +00:00
Compare commits
16 Commits
v5.1.2-tf.1
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,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,6 +269,44 @@ 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());
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
|
||||
@@ -24,11 +25,13 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
|
||||
private final Runnable onUnreachable;
|
||||
private final Set<String> signInHosts;
|
||||
/** A page of the server has shown since launch; from then on no sign-in host is learned (SignInRedirect). */
|
||||
private boolean serverShown;
|
||||
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
|
||||
super(bridge);
|
||||
this.onUnreachable = onUnreachable;
|
||||
this.signInHosts = signInHosts;
|
||||
this.signInHosts = new HashSet<>(signInHosts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +46,18 @@ 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);
|
||||
}
|
||||
|
||||
@@ -50,6 +65,7 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
@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"))
|
||||
|
||||
@@ -60,10 +60,10 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
|
||||
/// 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)
|
||||
@@ -185,6 +185,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 +435,44 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sign-in page in front of the server that the address screen never saw.
|
||||
/// A proxy can let the app's check through (an Authelia or Authentik rule that
|
||||
/// bypasses /api) and still send the page itself to its sign-in. Handed to
|
||||
/// Safari, that sign-in leaves its session there and the app stays blank on
|
||||
/// every launch. So a redirect away from the server, while the server's first
|
||||
/// page is still loading, is taken as the proxy's sign-in: it loads in the web
|
||||
/// view and its host is kept, the way the address screen keeps the ones it saw.
|
||||
/// A link the user taps is no redirect, and once a page of the server has
|
||||
/// shown, nothing is learned any more.
|
||||
enum TrekSignInRedirect {
|
||||
/// The host (host:port with an explicit port) to keep as a sign-in host, or nil.
|
||||
static func learnedHost(server: String?, target: URL?, mainFrame: Bool, redirect: Bool, serverShown: Bool) -> String? {
|
||||
guard mainFrame, redirect, !serverShown,
|
||||
let server, let from = URL(string: server), let fromHost = from.host?.lowercased(),
|
||||
let target, let toHost = target.host?.lowercased(),
|
||||
let toScheme = target.scheme?.lowercased(), ["https", "http"].contains(toScheme) else { return nil }
|
||||
// Never from https down to plain http: the sign-in would send a password in the clear.
|
||||
if from.scheme?.lowercased() == "https", toScheme != "https" { return nil }
|
||||
guard toHost != fromHost else { return nil }
|
||||
if let port = target.port { return "\(toHost):\(port)" }
|
||||
return toHost
|
||||
}
|
||||
|
||||
/// Whether `url` is on the server's host, where the app's own pages come from.
|
||||
static func onServer(server: String?, url: URL?) -> Bool {
|
||||
guard let server, let host = URL(string: server)?.host?.lowercased() else { return false }
|
||||
return url?.host?.lowercased() == host
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in front of Capacitor's navigation delegate and hands it everything
|
||||
/// but the failures of a navigation that another one replaced: a cancelled
|
||||
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
|
||||
/// are no fault, and Capacitor would answer them with its error page.
|
||||
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
private let inner: WKNavigationDelegate
|
||||
/// A page of the server has shown since launch; from then on no sign-in host is learned (TrekSignInRedirect).
|
||||
private var serverShown = false
|
||||
|
||||
init(inner: WKNavigationDelegate) {
|
||||
self.inner = inner
|
||||
@@ -462,6 +501,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 +525,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
|
||||
@@ -562,6 +564,36 @@ extension TrekShellPlugin {
|
||||
/// already there opens in Wallet instead of being offered twice. Resolves with
|
||||
/// `result` added, opened or cancelled; rejects INVALID for bytes that are no
|
||||
/// pass and UNAVAILABLE where Wallet cannot take one.
|
||||
/// Kept while Quick Look shows a file: the controller holds its data source weakly.
|
||||
private 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"))
|
||||
self.previewSource = source
|
||||
let preview = QLPreviewController()
|
||||
preview.dataSource = source
|
||||
presenter.present(preview, animated: true)
|
||||
call.resolve(["result": "shown"])
|
||||
}
|
||||
}
|
||||
|
||||
@objc func addToWallet(_ call: CAPPluginCall) {
|
||||
guard let base64 = call.getString("data"), let data = Data(base64Encoded: base64),
|
||||
let pass = try? PKPass(data: data) else {
|
||||
@@ -957,3 +989,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-21
@@ -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: '/' });
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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,10 +1,14 @@
|
||||
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' });
|
||||
@@ -51,3 +55,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) {
|
||||
|
||||
@@ -103,6 +103,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. */
|
||||
|
||||
@@ -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,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
+889
-1527
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -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",
|
||||
|
||||
+22
-22
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(/"<>/);
|
||||
});
|
||||
});
|
||||
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user