Compare commits

..

15 Commits

Author SHA1 Message Date
Maurice 2f03554a60 feat(native): files open in the system viewer instead of the share sheet
A PDF, image or document opened in the app went straight to the share sheet.
It now opens in Quick Look on iOS, to page through and zoom with its own share
button, and in the app that views its type on Android. An app without the
viewer (feature filePreview), or a type nothing on the device shows, gets the
share sheet as before.
2026-11-10 00:21:28 +01:00
Maurice 28079197e7 fix(assistant): an answer that hands the talk on to a trip or journey chat ends the turn
The home assistant wrote its answer and offered the trip's own chat in the
same step. The loop asked the model once more after the card, and it wrote
the same answer a second time below it. A step that has its answer and only
hands the conversation on now finishes the turn; a hand-on without an answer,
or beside a read, still asks the model again.
2026-11-10 00:14:44 +01:00
Maurice 0b46b89b62 fix(mobile): swipe actions are rounded tiles beside the row, icon only; expenses keep their own buttons
The actions under a swiped row were square blocks to the row's edge, unlike
the rounded cards around them. They are now tiles of their own with the
card's radius and a gap, inset within a grouped card such as the packing
list, and show only their icon (the label names them for a screen reader).
An expense already shows edit and delete beside its card, so it no longer
swipes to a second, larger pair of the same buttons.
2026-11-10 00:11:50 +01:00
Maurice 05caf67bc4 chore(notices): the release popup comes back for 5.1.3 2026-11-09 23:43:24 +01:00
Maurice 6d3c400e4a ci(native): build the iOS app with Xcode 27.1 on the xcode-27 image
An app linked against an SDK older than iOS 27.1 runs letterboxed on the inner
display of an iPhone Duo. The iOS build and the release now run on GitHub's
xcode-27 image and select Xcode 27.1; the previews stay on Xcode 26.
2026-11-09 23:33:23 +01:00
Maurice 9932288b04 chore(deps): bump the npm minor and patch group, with React's override and one MikroORM
From #3032, adjusted where it broke:
- react and react-dom are pinned in the root overrides; the group raised the
  workspaces to 19.3.1 and left the override at 19.3.0, so two react-dom
  versions met in every client test. The override moves along.
- @mikro-orm/nestjs kept the old @mikro-orm/core at the root while the server
  got 7.2.8 of its own, two copies whose types do not match; the lockfile now
  holds one 7.2.8.
- mapbox-gl stays at 3.31: 3.34 drops setRTLTextPlugin from its types, which
  the map engine uses.
- the musl pins of rollup stay at 4.62, the rollup version the lockfile holds.
2026-11-09 23:31:38 +01:00
Maurice 56b13541bd chore(native): keep the handoff start path inside browserSignIn, nothing else reads it 2026-11-09 23:29:38 +01:00
Maurice d568e20f02 chore(plugin-sdk): bump vitest and @vitest/coverage-v8 to 5 together
The two only resolve as a pair; one at a time (#3028, #3029) npm refuses the
peer range.
2026-11-09 23:28:26 +01:00
Maurice f03d59bc22 chore(deps): bump @capacitor/cli to 8.6, vite-plugin-pwa to 2 and @nestjs/schedule to 12
@capacitor/cli 8.6 drops the old uuid 7 through its xcode dependency (#3012).
vite-plugin-pwa 2 keeps the registerSW.js in public/ in place of its own, as
1.x did (#3033). @nestjs/schedule 12 still runs on Nest 11 (#3035).
2026-11-09 23:27:18 +01:00
dependabot[bot] 0833ca28c2 chore(plugin-sdk): bump semver
Bumps the plugin-sdk-minor-and-patch group in /plugin-sdk with 1 update: [semver](https://github.com/npm/node-semver).


Updates `semver` from 7.8.5 to 7.8.6
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.5...v7.8.6)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 7.8.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: plugin-sdk-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:18 +01:00
dependabot[bot] 3ed5f21a48 ci: bump actions/setup-java from 5 to 6
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5 to 6.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:18 +01:00
dependabot[bot] 2eb6b79bea ci: bump the actions-minor-and-patch group with 2 updates
Bumps the actions-minor-and-patch group with 2 updates: [docker/login-action](https://github.com/docker/login-action) and [SonarSource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action).


Updates `docker/login-action` from 4.7.0 to 4.8.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/81c6f93d4bc991e510b342a00b419b3bb4efc5c6...8aa5831fb8d48d8f76594c7ddde61ae58019f550)

Updates `SonarSource/sonarqube-scan-action` from 8.4.0 to 8.4.1
- [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases)
- [Commits](https://github.com/sonarsource/sonarqube-scan-action/compare/4d1dc0c91ee400dcf8a6dddf2f705ac36542e304...e805d875275f728fac1885b413934be6d7c63f65)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-and-patch
- dependency-name: SonarSource/sonarqube-scan-action
  dependency-version: 8.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:17 +01:00
Maurice 8dd2e9716d test(native): fix the sign-in handoff tests' quoting and formatting 2026-11-09 23:15:09 +01:00
Maurice 1cdde6e9a4 fix(native): a field on a page as tall as the screen comes up above the keyboard on iOS
The login on an iPhone scrolls in no pane of its own, and the keyboard plugin
holds the body's height, so the page had nothing to scroll by and the password
field stayed under the keyboard, most of all with an error shown above it. Such
a page now gets room as tall as the keyboard at its end and scrolls the field
above it; the room goes with the keyboard.
2026-11-09 23:12:27 +01:00
Maurice 034cfb539a fix(native): the app's browser sign-in no longer lands on the dashboard when the browser still holds an older TREK
A browser that ran TREK before 5.1.1 keeps that version's app in its service
worker. When the app opened /native-handoff, the worker answered it from its
cache with an app that has no such page, which sent the user to /, and the
sign-in ended on the dashboard in the browser instead of in the app.

The app now opens /api/auth/native/start, which every TREK service worker
passes to the server. That page takes the workers off the browser and
forwards to the handoff page. Until the sign-in is handed back, the web app
registers no service worker and does not reload for a new version: a tab
still open on the older TREK keeps its worker alive, and a registration or a
reload in the middle of the sign-in brought it back to answer the next page.
2026-11-09 23:02:33 +01:00
43 changed files with 1827 additions and 2022 deletions
+2 -2
View File
@@ -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 }}
+2 -2
View File
@@ -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 }}
+8 -6
View File
@@ -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:
+8 -6
View File
@@ -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
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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,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());
+56 -1
View File
@@ -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
View File
@@ -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"
}
}
+43
View File
@@ -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();
});
})();
+18
View File
@@ -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', () => {
+21 -6
View File
@@ -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">
+8 -4
View File
@@ -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];
+8 -1
View File
@@ -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);
});
});
+31
View File
@@ -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 */
}
}
+23
View File
@@ -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);
+17 -1
View File
@@ -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;
+38 -1
View File
@@ -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'] });
});
});
+34
View File
@@ -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) {
+10
View File
@@ -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');
+4 -4
View File
@@ -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
+24
View File
@@ -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()
+4
View File
@@ -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();
});
});
+889 -1527
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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",
+238 -384
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* 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)
+1 -1
View File
@@ -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',
+10
View File
@@ -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}&amp;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&amp;start=oidc"></script>',
);
expect(page).toContain('<meta http-equiv="refresh" content="0;url=/native-handoff?challenge=a&amp;start=oidc">');
expect(nativeStartPage('/native-handoff?x="<>')).not.toMatch(/"<>/);
});
});
+3 -3
View File
@@ -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"
}
}