mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bcfc1bfcb | |||
| b6e3c9bf39 | |||
| 736dc5dda9 | |||
| ae26a6c61c | |||
| 8979bd1fcd | |||
| 74d243d8e0 | |||
| 56b9bac031 | |||
| 4983b3407c | |||
| ed1728feec | |||
| 11472c05ff | |||
| f2ac12ca4f | |||
| b53130ca0d | |||
| a3a4f7cf79 | |||
| ac7094df05 | |||
| 80130f7946 | |||
| fc648bc7e4 | |||
| 9005d6b333 | |||
| 52f0fb882f | |||
| b8ec428ad1 | |||
| a1d3ffc2ce | |||
| 46b9df54bf | |||
| 3c0c3b3f6e | |||
| 5395d53332 | |||
| ec08032e98 | |||
| cc5eca5553 | |||
| 1323183cce | |||
| 009be05d22 | |||
| 860ffc1dee | |||
| c515f95d17 | |||
| 855bf8610a | |||
| 742edc4a79 | |||
| 1975a23aab | |||
| 80c8d9c602 | |||
| 948c9ef57b | |||
| bac968c0bf | |||
| 520562ef86 | |||
| 001253c39b | |||
| 5345b50d17 | |||
| a3622e91b6 | |||
| be305c3daf | |||
| ec78cfbdd0 | |||
| 8023be452a | |||
| 5112c96c05 | |||
| 04b1fb72bb | |||
| a9d11ccb2e | |||
| 848e3f90e1 | |||
| e83b6741ad | |||
| 0a3d93b59b | |||
| 102e2141ec | |||
| 768fd6f439 | |||
| 231a610466 | |||
| 7db5ec0721 | |||
| a2b9d96e86 | |||
| e0022c12c4 | |||
| 66b8f2c906 | |||
| 6fbe10fe63 | |||
| 278871a31e | |||
| bdcef33a3e | |||
| 8e8cff8105 | |||
| 6b086eaed5 | |||
| 0ac06555cb | |||
| 1b87c62847 | |||
| 98eae61b90 | |||
| 19956f2742 | |||
| 7506eaa4aa | |||
| 57c9c9d5f2 | |||
| 0b3d2873c2 | |||
| 1454b58ea1 | |||
| 2ab4459bc3 | |||
| 6ebb92af6c | |||
| 3ab4927cb7 | |||
| 6a0ae482d7 | |||
| 20eeaeb61f | |||
| 5cbe4e2fa3 | |||
| b124e1bc9c | |||
| 08034c4402 | |||
| 0577a9ccb9 | |||
| 2f03554a60 | |||
| 28079197e7 | |||
| 0b46b89b62 | |||
| 05caf67bc4 | |||
| 6d3c400e4a | |||
| 9932288b04 | |||
| 56b13541bd | |||
| d568e20f02 | |||
| f03d59bc22 | |||
| 0833ca28c2 | |||
| 3ed5f21a48 | |||
| 2eb6b79bea | |||
| 8dd2e9716d | |||
| 1cdde6e9a4 | |||
| 034cfb539a | |||
| 5420077b98 | |||
| 5cfc7a602a | |||
| b48e344749 | |||
| e58a880f34 | |||
| 17ff7f6bd8 | |||
| f03bb97d50 | |||
| ed0142be7d | |||
| 6e9f89a25f | |||
| a3c7c3d76a | |||
| c043ca4bba | |||
| 004c6212c0 | |||
| 07448e662c | |||
| d3bdc6de62 | |||
| a9bfcd0fd5 | |||
| 12caa7fbde | |||
| 1cbd4acf01 | |||
| 687e477ca5 | |||
| 3a66f2a8da | |||
| 7f52e424e8 | |||
| 2479e5661b | |||
| bc2fb1ce2d | |||
| ffa07b3d05 | |||
| 306753d6a1 | |||
| 80c04df4fc | |||
| c9a91bbdad | |||
| d06168d94a | |||
| d9baaa8957 | |||
| fede671f3b | |||
| b3113e66b9 | |||
| 286b0b09ec | |||
| c0b39abe18 | |||
| 6e3a6386b8 | |||
| 5e7d778740 | |||
| 3be87343b8 | |||
| 517efabbca | |||
| 1866dca818 | |||
| 1400b5d308 | |||
| b529cf4e57 | |||
| 6cc3d235b1 | |||
| a5b1156612 | |||
| c6ffd48e26 | |||
| afb6eae4c8 | |||
| cd1f39377d | |||
| 3a99e8576d | |||
| 36c8c5bc48 | |||
| 4ba7db90e6 | |||
| ab6cd7009e | |||
| b16ac683cf | |||
| 95d62b7a02 | |||
| e444e855c0 | |||
| 4ab42c06c9 | |||
| 9f6ab11839 | |||
| 5f711d5374 | |||
| 4d550112a1 | |||
| 26dc88181d | |||
| 3d4f8bfe12 | |||
| 5fb1838c7a | |||
| 38e5d76ad2 | |||
| a9af19f02a | |||
| 305b00ae01 | |||
| ed6724035e | |||
| eea83bb1c9 | |||
| 06c03cbded | |||
| b73f749880 | |||
| 53c18b861a | |||
| 9b1bf89382 | |||
| c7f144d3ff | |||
| cb41f08199 | |||
| eafb30555c | |||
| 4f1f48eec5 | |||
| 42341c2c76 | |||
| 6a60fc183a | |||
| 236b4c34ac | |||
| c6cb3dc64a | |||
| 3fc7487b58 | |||
| ac055d531f | |||
| 0ef3cbf520 | |||
| 3458238db0 | |||
| afaacec61a | |||
| 5f4fb5dae7 |
@@ -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 }}
|
||||
|
||||
@@ -140,7 +140,9 @@ jobs:
|
||||
set -eu
|
||||
|
||||
# Derive version from git tags, no package.json dependency
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
# Only a plain vX.Y.Z is a release: the glob alone also matches the apps' TestFlight tags
|
||||
# (v5.1.0-tf.15), whose suffix then broke the arithmetic below.
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
STABLE="${STABLE_TAG#v}"
|
||||
STABLE="${STABLE:-0.0.0}"
|
||||
|
||||
@@ -342,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 }}
|
||||
@@ -401,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
|
||||
@@ -121,6 +123,21 @@ jobs:
|
||||
working-directory: client
|
||||
run: npm run cap:sync -- ios
|
||||
|
||||
# Capacitor's xcframeworks come from GitHub release downloads, which answer
|
||||
# 504 now and then; SwiftPM then reports the half-written file as "already
|
||||
# exists in file system". Resolve on a clean artifact cache, a few times
|
||||
# with growing pauses, before the build needs the packages.
|
||||
- name: Resolve the Swift packages
|
||||
working-directory: client/ios/App
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
|
||||
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
|
||||
# No -sdk: the scheme builds the embedded watch app and its complications
|
||||
# against the watchOS simulator SDK and the rest against the iOS one.
|
||||
- name: Build for the simulator
|
||||
@@ -136,7 +153,10 @@ jobs:
|
||||
# The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and
|
||||
# dark, for a trip ahead, a trip under way and none, plus a home screen and
|
||||
# a lock screen of them. Nobody has to install a build to see a widget change.
|
||||
# Started by hand only (Run workflow): the apps are on TestFlight and Play now,
|
||||
# where a change is looked at on a device; on every PR it only held the queue.
|
||||
widget-previews:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -193,7 +213,9 @@ jobs:
|
||||
# The watch app on watchOS simulators in its screenshot mode (DemoMode.swift):
|
||||
# every screen on the largest and the smallest watch, the main ones in every
|
||||
# language, and the complications, as PNGs to look at without a watch.
|
||||
# Started by hand only, like the widget previews: up to 90 minutes of macOS.
|
||||
watch-screens:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
@@ -220,6 +242,21 @@ jobs:
|
||||
xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS
|
||||
xcrun simctl list runtimes
|
||||
|
||||
# Capacitor's xcframeworks come from GitHub release downloads, which answer
|
||||
# 504 now and then; SwiftPM then reports the half-written file as "already
|
||||
# exists in file system". Resolve on a clean artifact cache, a few times
|
||||
# with growing pauses, before the build needs the packages.
|
||||
- name: Resolve the Swift packages
|
||||
working-directory: client/ios/App
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
|
||||
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Build the watch app for the simulator
|
||||
working-directory: client/ios/App
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
@@ -1236,10 +1236,12 @@ jobs:
|
||||
bin="$RUNNER_TEMP/bin"
|
||||
mkdir -p "$bin"
|
||||
cd "$RUNNER_TEMP"
|
||||
curl -sSfLo helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
|
||||
# GitHub's release downloads answer 504 now and then: retry, the checksum still decides.
|
||||
fetch() { curl -sSfL --retry 5 --retry-delay 15 --retry-all-errors -o "$1" "$2"; }
|
||||
fetch helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
|
||||
echo "$HELM_SHA256 helm.tgz" | sha256sum -c -
|
||||
tar -xzf helm.tgz --strip-components=1 -C "$bin" linux-amd64/helm
|
||||
curl -sSfLo kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
|
||||
fetch kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
|
||||
echo "$KUBECONFORM_SHA256 kubeconform.tgz" | sha256sum -c -
|
||||
tar -xzf kubeconform.tgz -C "$bin" kubeconform
|
||||
echo "$bin" >> "$GITHUB_PATH"
|
||||
|
||||
@@ -70,7 +70,10 @@ COPY server/package.json ./server/
|
||||
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
|
||||
# deps are esbuild's binary check, redundant next to its optional platform
|
||||
# package, and a telemetry ping.
|
||||
# The upgrade takes the Debian security fixes the base image was built before
|
||||
# (zlib, 11/2026, failed the image scan until node:24-trixie-slim caught up).
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y --no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
|
||||
libkitinerary-bin libsqlite3-0 && \
|
||||
npm ci --workspace=server --omit=dev --ignore-scripts && \
|
||||
|
||||
@@ -20,7 +20,23 @@ geoBoundaries is licensed under **CC BY 4.0**
|
||||
The bundled files are simplified (coordinate-quantized) and re-tagged with the
|
||||
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
|
||||
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
|
||||
(gbOpen) release.
|
||||
(gbOpen) release, except for Ireland (below).
|
||||
|
||||
## Tailte Éireann — counties of Ireland
|
||||
|
||||
Ireland's regions in the same files (`admin1/IE.geojson.gz`, and its features in
|
||||
`admin1.geojson.gz`) are the 26 counties from Tailte Éireann's *Counties - National
|
||||
Statutory Boundaries - 2019 - Generalised 20m*, because geoBoundaries has no county
|
||||
layer for Ireland.
|
||||
|
||||
Contains Tailte Éireann data, © Tailte Éireann, licensed under **CC BY 4.0**
|
||||
(https://creativecommons.org/licenses/by/4.0/). Source:
|
||||
https://data-osi.opendata.arcgis.com/
|
||||
|
||||
The outlines are simplified, coordinate-quantized and re-tagged with ISO 3166-2:IE
|
||||
codes by `server/scripts/build-atlas-geo.mjs`. Tailte Éireann publishes the
|
||||
generalised boundaries for reference only; they may not match the statutory
|
||||
boundaries exactly.
|
||||
|
||||
## OpenStreetMap — geocoding, place details & descriptions
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
#### 🗺️ Atlas, Vacay and Collections
|
||||
|
||||
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
|
||||
- **Vacay**: leave calendar with half days, public holidays from [nagerholidays.com](https://nagerholidays.com), school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
|
||||
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
|
||||
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
|
||||
|
||||
@@ -276,6 +276,8 @@ Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine.
|
||||
|
||||
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
||||
|
||||
**TrueNAS SCALE:** Install TREK directly from the Community catalog. See the [TrueNAS SCALE installation guide](https://github.com/liketrek/TREK/wiki/Install-TrueNAS-SCALE).
|
||||
|
||||
> [!TIP]
|
||||
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
|
||||
|
||||
@@ -348,7 +350,7 @@ TREK then launches fullscreen with its own icon, just like a native app.
|
||||
## Updating
|
||||
|
||||
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
|
||||
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note.
|
||||
Docker run, Helm, Portainer, Unraid, TrueNAS SCALE and Proxmox, plus the encryption-key note.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -437,8 +439,9 @@ Every variable, its default and what it does: see
|
||||
|
||||
The Atlas map's country and sub-national (province/county) boundaries come from
|
||||
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
|
||||
for full third-party attributions.
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); Ireland's counties come from
|
||||
[**Tailte Éireann**](https://data-osi.opendata.arcgis.com/), also CC BY 4.0. See
|
||||
[NOTICE.md](NOTICE.md) for full third-party attributions.
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.1.0
|
||||
version: 5.1.4
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.1.0"
|
||||
appVersion: "5.1.4"
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PW
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
|
||||
npm run build # prebuild generates PWA icons, then vite build
|
||||
npm run build # prebuild generates PWA icons, then vite build; postbuild writes a Brotli copy beside each hashed asset for the server to hand out
|
||||
npm run typecheck # tsc --noEmit (CI)
|
||||
npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it)
|
||||
npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors)
|
||||
|
||||
@@ -9,7 +9,9 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-action-sheet')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-dialog')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-keyboard')
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.os.Bundle;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.KeyboardShortcutGroup;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import com.liketrek.trek.screen.TrekKeyCommands;
|
||||
import com.liketrek.trek.tiles.TrekTileStore;
|
||||
@@ -26,6 +34,7 @@ public class MainActivity extends BridgeActivity {
|
||||
// runs and on its own origin. BridgeActivity reads `config` while it
|
||||
// builds the bridge inside super.onCreate().
|
||||
boolean restarting = TrekServerStore.consumeRestart(this);
|
||||
TrekServerStore.recheckAfterUpdate(this);
|
||||
String server = TrekServerStore.getUrl(this);
|
||||
boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this);
|
||||
config = TrekServerStore.configFor(this, onServer ? server : null, restarting);
|
||||
@@ -36,7 +45,17 @@ public class MainActivity extends BridgeActivity {
|
||||
// scroller natively. It scales the whole view, top bar and bottom dock included.
|
||||
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
// Set before any WebView callback can run: they are posted to this thread.
|
||||
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
|
||||
if (onServer) {
|
||||
bridge.setWebViewClient(
|
||||
new TrekWebViewClient(
|
||||
bridge,
|
||||
this::showAddressScreen,
|
||||
this::showSignInBar,
|
||||
this::showErrorPageBar,
|
||||
TrekServerStore.getSignInHosts(this)
|
||||
)
|
||||
);
|
||||
}
|
||||
TrekSharedFiles.receive(this, getIntent());
|
||||
// Android 15's widget picker draws the widgets live from a sample trip.
|
||||
TrekWidgetUpdater.publishPreviews(this);
|
||||
@@ -68,6 +87,122 @@ public class MainActivity extends BridgeActivity {
|
||||
|
||||
private boolean leavingServer;
|
||||
|
||||
private View signInBar;
|
||||
/** Whether the page is a proxy's sign-in page, and whether the keyboard is up over it. */
|
||||
private boolean onSignInPage;
|
||||
/** Whether the page is an error page in place of the server's (TrekWebViewClient.onReceivedHttpError). */
|
||||
private boolean onErrorPage;
|
||||
private boolean keyboardUp;
|
||||
private TextView signInHint;
|
||||
|
||||
/**
|
||||
* Over a proxy's sign-in page (Pangolin, authentik, ...): the server's UI is
|
||||
* not there, so nothing else would lead back to the address screen, and a
|
||||
* passkey cannot work in a web view for a domain the app is not tied to.
|
||||
* The bar says so and offers to change the server.
|
||||
*/
|
||||
private void showSignInBar(boolean show) {
|
||||
runOnUiThread(() -> {
|
||||
onSignInPage = show;
|
||||
if (show && signInBar == null) signInBar = buildSignInBar();
|
||||
updateSignInBar();
|
||||
});
|
||||
}
|
||||
|
||||
/** The same bar over an error page in place of the server's, with words for that. */
|
||||
private void showErrorPageBar(boolean show) {
|
||||
runOnUiThread(() -> {
|
||||
onErrorPage = show;
|
||||
if (show && signInBar == null) signInBar = buildSignInBar();
|
||||
updateSignInBar();
|
||||
});
|
||||
}
|
||||
|
||||
/** Out of the way while the keyboard is up: the form needs the room, and the bar is there again once it closes. */
|
||||
private void updateSignInBar() {
|
||||
if (signInBar == null) return;
|
||||
if (signInHint != null) {
|
||||
signInHint.setText(onSignInPage ? TrekServerStore.signInHintLabel(this) : TrekServerStore.errorPageHintLabel(this));
|
||||
}
|
||||
signInBar.setVisibility((onSignInPage || onErrorPage) && !keyboardUp ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private View buildSignInBar() {
|
||||
float dp = getResources().getDisplayMetrics().density;
|
||||
LinearLayout bar = new LinearLayout(this);
|
||||
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
bar.setGravity(Gravity.CENTER_VERTICAL);
|
||||
bar.setPaddingRelative(Math.round(16 * dp), Math.round(10 * dp), Math.round(10 * dp), Math.round(10 * dp));
|
||||
// The look of the app's own first screens: a white card with a sand edge,
|
||||
// warm grey text and a dark button, over whatever page the proxy shows.
|
||||
GradientDrawable background = new GradientDrawable();
|
||||
background.setColor(0xF7FFFFFF);
|
||||
background.setCornerRadius(18 * dp);
|
||||
background.setStroke(Math.max(1, Math.round(dp)), 0xFFEFE4D8);
|
||||
bar.setBackground(background);
|
||||
bar.setElevation(8 * dp);
|
||||
|
||||
TextView hint = new TextView(this);
|
||||
signInHint = hint;
|
||||
hint.setText(TrekServerStore.signInHintLabel(this));
|
||||
hint.setTextColor(0xFF6B5F57);
|
||||
hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12.5f);
|
||||
bar.addView(hint, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
|
||||
|
||||
Button change = new Button(this, null, android.R.attr.borderlessButtonStyle);
|
||||
change.setText(TrekServerStore.changeServerLabel(this));
|
||||
change.setAllCaps(false);
|
||||
change.setTextColor(0xFFFFFFFF);
|
||||
GradientDrawable pill = new GradientDrawable();
|
||||
pill.setColor(0xFF111827);
|
||||
pill.setCornerRadius(12 * dp);
|
||||
change.setBackground(pill);
|
||||
change.setMinHeight(0);
|
||||
change.setMinimumHeight(0);
|
||||
change.setPaddingRelative(Math.round(14 * dp), Math.round(9 * dp), Math.round(14 * dp), Math.round(9 * dp));
|
||||
LinearLayout.LayoutParams changeParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
changeParams.setMarginStart(Math.round(10 * dp));
|
||||
change.setOnClickListener((v) -> {
|
||||
TrekServerStore.setUrl(this, null);
|
||||
TrekServerStore.markRestart(this);
|
||||
recreate();
|
||||
});
|
||||
bar.addView(change, changeParams);
|
||||
|
||||
int margin = Math.round(12 * dp);
|
||||
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM
|
||||
);
|
||||
params.setMargins(margin, 0, margin, margin);
|
||||
FrameLayout root = findViewById(android.R.id.content);
|
||||
root.addView(bar, params);
|
||||
// Above the gesture bar, and above the keyboard while it is up.
|
||||
bar.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||
// Read off the window's own insets: Capacitor hands the views below the
|
||||
// decor a copy with the system bars zeroed, and before Android 11 that
|
||||
// takes the keyboard with it.
|
||||
androidx.core.view.WindowInsetsCompat window = androidx.core.view.ViewCompat.getRootWindowInsets(getWindow().getDecorView());
|
||||
int bottom = window != null
|
||||
? window.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom
|
||||
: insets.getStableInsetBottom();
|
||||
boolean imeVisible = window != null && window.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime());
|
||||
if (imeVisible != keyboardUp) {
|
||||
keyboardUp = imeVisible;
|
||||
updateSignInBar();
|
||||
}
|
||||
FrameLayout.LayoutParams p = (FrameLayout.LayoutParams) v.getLayoutParams();
|
||||
if (p.bottomMargin != margin + bottom) {
|
||||
p.bottomMargin = margin + bottom;
|
||||
v.setLayoutParams(p);
|
||||
}
|
||||
return insets;
|
||||
});
|
||||
bar.requestApplyInsets();
|
||||
return bar;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server did not load: back to the address screen, which offers retry
|
||||
* and change. A fresh task rather than recreate(), because this usually
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* A sign-in page in front of the server that the address screen never saw.
|
||||
* A proxy can let the app's check through (an Authelia or Authentik rule that
|
||||
* bypasses /api) and still send the page itself to its sign-in. Handed to the
|
||||
* browser, that sign-in leaves its session there and the app stays blank on
|
||||
* every launch. So a redirect away from the server, while the server's first
|
||||
* page is still loading, is taken as the proxy's sign-in: it loads in the web
|
||||
* view and its host is kept, the way the address screen keeps the ones it saw.
|
||||
* A link the user follows is no redirect, and once a page of the server has
|
||||
* shown, nothing is learned any more.
|
||||
*/
|
||||
final class SignInRedirect {
|
||||
|
||||
private SignInRedirect() {}
|
||||
|
||||
/** The host (host:port with an explicit port) to keep as a sign-in host, or null. */
|
||||
static String learnedHost(String server, String target, boolean mainFrame, boolean redirect, boolean serverShown) {
|
||||
if (!mainFrame || !redirect || serverShown || server == null || target == null) return null;
|
||||
URI from;
|
||||
URI to;
|
||||
try {
|
||||
from = new URI(server);
|
||||
to = new URI(target);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
String fromHost = lower(from.getHost());
|
||||
String toHost = lower(to.getHost());
|
||||
String toScheme = lower(to.getScheme());
|
||||
if (fromHost == null || toHost == null) return null;
|
||||
if (!"https".equals(toScheme) && !"http".equals(toScheme)) return null;
|
||||
// Never from https down to plain http: the sign-in would send a password in the clear.
|
||||
if ("https".equals(lower(from.getScheme())) && !"https".equals(toScheme)) return null;
|
||||
if (toHost.equals(fromHost)) return null;
|
||||
return to.getPort() == -1 ? toHost : toHost + ":" + to.getPort();
|
||||
}
|
||||
|
||||
/** Whether `url` is on the server's host, where the app's own pages come from. */
|
||||
static boolean onServer(String server, String url) {
|
||||
if (server == null || url == null) return false;
|
||||
try {
|
||||
String serverHost = lower(new URI(server).getHost());
|
||||
return serverHost != null && serverHost.equals(lower(new URI(url).getHost()));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String lower(String value) {
|
||||
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -18,27 +22,158 @@ final class TrekServerStore {
|
||||
|
||||
private static final String PREFS = "trek_shell";
|
||||
private static final String KEY_URL = "server_url";
|
||||
private static final String KEY_PREVIOUS_URL = "previous_server_url";
|
||||
private static final String KEY_SERVER_CHECK = "server_check";
|
||||
private static final String KEY_LABEL_SIGN_IN_HINT = "label_sign_in_hint";
|
||||
private static final String KEY_LABEL_CHANGE_SERVER = "label_change_server";
|
||||
private static final String KEY_LABEL_ERROR_PAGE_HINT = "label_error_page_hint";
|
||||
|
||||
/**
|
||||
* The server API this build needs (MIN_NATIVE_API in serverAddress.ts). A
|
||||
* server remembered under a lower value is checked once more: the first
|
||||
* launch after an update that raised it puts the address screen back up,
|
||||
* filled in with that server (getPreviousUrl). Raise both together.
|
||||
*/
|
||||
private static final int SERVER_CHECK = 3;
|
||||
private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts";
|
||||
private static final String KEY_SHELL_ONCE = "shell_once";
|
||||
private static final String KEY_RESTART = "restart";
|
||||
private static final String KEY_WELCOME = "welcome";
|
||||
private static final String CONFIG_DIR = "trek-server-config";
|
||||
|
||||
private TrekServerStore() {}
|
||||
|
||||
/**
|
||||
* The one-time code of a TREK Cloud purchase (setServer's `welcome`). The
|
||||
* server's first page opens its welcome page with it once, which signs the
|
||||
* buyer in (TrekWebViewClient). Kept only until the restart has loaded.
|
||||
*/
|
||||
static void setWelcome(Context context, String code) {
|
||||
SharedPreferences.Editor editor = prefs(context).edit();
|
||||
if (code == null) editor.remove(KEY_WELCOME);
|
||||
else editor.putString(KEY_WELCOME, code);
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
/** The welcome page for the pending code, handed out once; null when there is none. */
|
||||
static String takeWelcomeUrl(Context context) {
|
||||
String code = prefs(context).getString(KEY_WELCOME, null);
|
||||
String server = getUrl(context);
|
||||
if (code == null) return null;
|
||||
prefs(context).edit().remove(KEY_WELCOME).commit();
|
||||
if (server == null) return null;
|
||||
return (server.endsWith("/") ? server : server + "/") + "api/managed/welcome#" + code;
|
||||
}
|
||||
|
||||
static String getUrl(Context context) {
|
||||
return prefs(context).getString(KEY_URL, null);
|
||||
}
|
||||
|
||||
/** Runs before the bridge is built, so a server picked under an older minimum is not loaded unchecked. */
|
||||
static void recheckAfterUpdate(Context context) {
|
||||
SharedPreferences prefs = prefs(context);
|
||||
if (prefs.getInt(KEY_SERVER_CHECK, 0) >= SERVER_CHECK) return;
|
||||
String server = getUrl(context);
|
||||
SharedPreferences.Editor editor = prefs.edit().putInt(KEY_SERVER_CHECK, SERVER_CHECK);
|
||||
if (server != null) editor.remove(KEY_URL).remove(KEY_SIGN_IN_HOSTS).putString(KEY_PREVIOUS_URL, server);
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* The words of the bar over a proxy's sign-in page, in the user's language,
|
||||
* as the address screen passed them (setServer's `labels`): the server's UI
|
||||
* is not there to say them. Null keeps the English fallback.
|
||||
*/
|
||||
static void setLabels(Context context, String signInHint, String changeServer, String errorPageHint) {
|
||||
SharedPreferences.Editor editor = prefs(context).edit();
|
||||
if (signInHint != null) editor.putString(KEY_LABEL_SIGN_IN_HINT, signInHint);
|
||||
if (changeServer != null) editor.putString(KEY_LABEL_CHANGE_SERVER, changeServer);
|
||||
if (errorPageHint != null) editor.putString(KEY_LABEL_ERROR_PAGE_HINT, errorPageHint);
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
static String errorPageHintLabel(Context context) {
|
||||
return prefs(context).getString(
|
||||
KEY_LABEL_ERROR_PAGE_HINT,
|
||||
"This page is not from TREK: the server or a proxy in front of it refused the app. Check your proxy or certificate, or change the server."
|
||||
);
|
||||
}
|
||||
|
||||
static String signInHintLabel(Context context) {
|
||||
return prefs(context).getString(
|
||||
KEY_LABEL_SIGN_IN_HINT,
|
||||
"Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
|
||||
);
|
||||
}
|
||||
|
||||
static String changeServerLabel(Context context) {
|
||||
return prefs(context).getString(KEY_LABEL_CHANGE_SERVER, "Change server");
|
||||
}
|
||||
|
||||
/** The server an update asked to check again, until a server is set. */
|
||||
static String getPreviousUrl(Context context) {
|
||||
return prefs(context).getString(KEY_PREVIOUS_URL, null);
|
||||
}
|
||||
|
||||
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
|
||||
static void setUrl(Context context, String url) {
|
||||
setServer(context, url, null);
|
||||
}
|
||||
|
||||
static void setServer(Context context, String url, Set<String> signInHosts) {
|
||||
SharedPreferences.Editor editor = prefs(context).edit();
|
||||
if (url == null) {
|
||||
editor.remove(KEY_URL);
|
||||
} else {
|
||||
editor.putString(KEY_URL, url);
|
||||
editor.putString(KEY_URL, url).remove(KEY_PREVIOUS_URL);
|
||||
}
|
||||
if (signInHosts == null || signInHosts.isEmpty()) {
|
||||
editor.remove(KEY_SIGN_IN_HOSTS);
|
||||
} else {
|
||||
Set<String> lower = new HashSet<>();
|
||||
for (String host : signInHosts) lower.add(host.toLowerCase(Locale.ROOT));
|
||||
editor.putStringSet(KEY_SIGN_IN_HOSTS, lower);
|
||||
}
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* The hosts of a sign-in page that stands in front of the server (a proxy
|
||||
* such as Cloudflare Access or Pangolin), as the address screen found them.
|
||||
* Only these may load inside the web view besides the server itself, so
|
||||
* the proxy's session lands where the app can use it (TrekWebViewClient).
|
||||
*/
|
||||
static Set<String> getSignInHosts(Context context) {
|
||||
Set<String> hosts = prefs(context).getStringSet(KEY_SIGN_IN_HOSTS, null);
|
||||
return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts));
|
||||
}
|
||||
|
||||
/** Keeps one more sign-in host for the remembered server, as the web view met it (SignInRedirect). */
|
||||
static void addSignInHost(Context context, String host) {
|
||||
Set<String> hosts = new HashSet<>(getSignInHosts(context));
|
||||
if (!hosts.add(host.toLowerCase(Locale.ROOT))) return;
|
||||
prefs(context).edit().putStringSet(KEY_SIGN_IN_HOSTS, hosts).commit();
|
||||
}
|
||||
|
||||
/** Start the next launch on the bundled address screen, even with a server remembered. */
|
||||
private static final String KEY_FAILURE = "last_failure";
|
||||
|
||||
/**
|
||||
* Why the server's page did not load, in the WebView's own terms
|
||||
* ("net::ERR_NAME_NOT_RESOLVED (-2)", "HTTP 503"): the address screen shows
|
||||
* it small under its message, so a report carries the cause.
|
||||
*/
|
||||
static void setFailure(Context context, String failure) {
|
||||
prefs(context).edit().putString(KEY_FAILURE, failure).commit();
|
||||
}
|
||||
|
||||
/** The failure behind this address screen, once. */
|
||||
static String takeFailure(Context context) {
|
||||
String failure = prefs(context).getString(KEY_FAILURE, null);
|
||||
if (failure != null) prefs(context).edit().remove(KEY_FAILURE).commit();
|
||||
return failure;
|
||||
}
|
||||
|
||||
static void showShellOnce(Context context) {
|
||||
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ final class TrekSharedFiles {
|
||||
file.put("mimeType", meta.optString("mimeType", "application/octet-stream"));
|
||||
file.put("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP));
|
||||
if (meta.has("tripId")) file.put("tripId", meta.optLong("tripId"));
|
||||
// A page or a link: where it came from, so the UI can keep it among the trip's links.
|
||||
String url = meta.optString("url", "");
|
||||
if (url.startsWith("https://") || url.startsWith("http://")) {
|
||||
file.put("url", url);
|
||||
file.put("title", meta.optString("title", url));
|
||||
}
|
||||
pending = file;
|
||||
if (listener != null) listener.onFile(file);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import androidx.activity.result.ActivityResult;
|
||||
import com.liketrek.trek.clientcert.ClientCertActions;
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity;
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import com.getcapacitor.JSArray;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.PermissionState;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.ActivityCallback;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import com.getcapacitor.annotation.Permission;
|
||||
import com.getcapacitor.annotation.PermissionCallback;
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** The native half of client/src/native/trekShell.ts. */
|
||||
@@ -29,6 +39,8 @@ import org.json.JSONObject;
|
||||
)
|
||||
public class TrekShellPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "TrekShell";
|
||||
|
||||
/** How long to wait after returning from the browser for the redirect to land. */
|
||||
private static final long CANCEL_GRACE_MS = 600;
|
||||
|
||||
@@ -59,6 +71,8 @@ public class TrekShellPlugin extends Plugin {
|
||||
"googleWallet",
|
||||
"keyCommands",
|
||||
"windowLayout",
|
||||
"clientCertificate",
|
||||
"filePreview",
|
||||
};
|
||||
|
||||
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
|
||||
@@ -256,11 +270,53 @@ public class TrekShellPlugin extends Plugin {
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file the UI wrote to the cache in the app that views it, a PDF in
|
||||
* the system's PDF viewer, instead of the share sheet. "unsupported" where no
|
||||
* app on the device views the type; the UI then hands it to the share sheet.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void previewFile(PluginCall call) {
|
||||
String path = call.getString("path");
|
||||
Uri parsed = path == null ? null : Uri.parse(path);
|
||||
java.io.File file = parsed != null && "file".equals(parsed.getScheme()) && parsed.getPath() != null ? new java.io.File(parsed.getPath()) : null;
|
||||
java.io.File cache = getContext().getCacheDir();
|
||||
boolean inCache;
|
||||
try {
|
||||
inCache = file != null && file.getCanonicalPath().startsWith(cache.getCanonicalPath() + java.io.File.separator);
|
||||
} catch (java.io.IOException e) {
|
||||
inCache = false;
|
||||
}
|
||||
if (!inCache || !file.isFile()) {
|
||||
call.reject("a file in the app's cache is required", "INVALID");
|
||||
return;
|
||||
}
|
||||
String type = call.getString("mimeType", "application/octet-stream");
|
||||
Uri content = androidx.core.content.FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file);
|
||||
Intent view = new Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(content, type)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
getActivity().runOnUiThread(() -> {
|
||||
JSObject result = new JSObject();
|
||||
try {
|
||||
getActivity().startActivity(view);
|
||||
result.put("result", "shown");
|
||||
} catch (ActivityNotFoundException e) {
|
||||
result.put("result", "unsupported");
|
||||
}
|
||||
call.resolve(result);
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getServer(PluginCall call) {
|
||||
String url = TrekServerStore.getUrl(getContext());
|
||||
String previous = TrekServerStore.getPreviousUrl(getContext());
|
||||
JSObject result = new JSObject();
|
||||
result.put("url", url == null ? JSONObject.NULL : url);
|
||||
result.put("previousUrl", previous == null ? JSONObject.NULL : previous);
|
||||
String failure = TrekServerStore.takeFailure(getContext());
|
||||
result.put("failure", failure == null ? JSONObject.NULL : failure);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@@ -272,11 +328,35 @@ public class TrekShellPlugin extends Plugin {
|
||||
call.reject("A http(s) URL is required");
|
||||
return;
|
||||
}
|
||||
TrekServerStore.setUrl(getContext(), url);
|
||||
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
|
||||
JSObject labels = call.getObject("labels");
|
||||
if (labels != null) {
|
||||
TrekServerStore.setLabels(
|
||||
getContext(),
|
||||
labels.getString("signInHint"),
|
||||
labels.getString("changeServer"),
|
||||
labels.getString("errorPageHint")
|
||||
);
|
||||
}
|
||||
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
|
||||
String welcome = call.getString("welcome");
|
||||
TrekServerStore.setWelcome(getContext(), welcome != null && welcome.matches("[A-Za-z0-9_-]{43}") ? welcome : null);
|
||||
call.resolve();
|
||||
restart();
|
||||
}
|
||||
|
||||
/** The sign-in hosts the address screen passes along, plain host names only. */
|
||||
private static Set<String> signInHostsOf(PluginCall call) {
|
||||
Set<String> hosts = new HashSet<>();
|
||||
JSArray list = call.getArray("signInHosts");
|
||||
if (list == null) return hosts;
|
||||
for (int i = 0; i < list.length(); i++) {
|
||||
String host = list.optString(i, "");
|
||||
if (host.matches("[A-Za-z0-9.-]+(:[0-9]+)?")) hosts.add(host);
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void resetServer(PluginCall call) {
|
||||
TrekServerStore.setUrl(getContext(), null);
|
||||
@@ -315,7 +395,32 @@ public class TrekShellPlugin extends Plugin {
|
||||
// A Custom Tab shares the browser's cookies, so a running SSO session at
|
||||
// the identity provider carries over. The redirect back to the app's
|
||||
// scheme arrives through the intent-filter in AndroidManifest.xml.
|
||||
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url));
|
||||
Uri target = Uri.parse(url);
|
||||
getActivity().runOnUiThread(() -> openSignIn(target));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the sign-in in a Custom Tab, or in the browser itself where no
|
||||
* Custom Tab can come up. Launched on the UI thread: some devices dropped a
|
||||
* launch from the bridge's thread without a word, and the sign-in then
|
||||
* looked cancelled. A browser that cannot open at all is reported as such.
|
||||
*/
|
||||
private void openSignIn(Uri target) {
|
||||
try {
|
||||
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), target);
|
||||
return;
|
||||
} catch (RuntimeException tabFailed) {
|
||||
Log.w(TAG, "No Custom Tab for the sign-in, trying the browser", tabFailed);
|
||||
}
|
||||
try {
|
||||
getActivity().startActivity(new Intent(Intent.ACTION_VIEW, target).addCategory(Intent.CATEGORY_BROWSABLE));
|
||||
} catch (RuntimeException browserFailed) {
|
||||
Log.w(TAG, "No browser for the sign-in", browserFailed);
|
||||
PluginCall call = pendingAuthCall();
|
||||
if (call == null) return;
|
||||
call.reject("No browser could open the sign-in", "NO_BROWSER");
|
||||
finishAuth(call);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -331,7 +436,11 @@ public class TrekShellPlugin extends Plugin {
|
||||
}
|
||||
Uri data = intent.getData();
|
||||
PluginCall call = pendingAuthCall();
|
||||
if (call == null || data == null || !authScheme.equals(data.getScheme())) return;
|
||||
if (call == null) {
|
||||
keepOrphanAuthReturn(intent);
|
||||
return;
|
||||
}
|
||||
if (data == null || !authScheme.equals(data.getScheme())) return;
|
||||
JSObject result = new JSObject();
|
||||
result.put("url", data.toString());
|
||||
call.resolve(result);
|
||||
@@ -357,6 +466,48 @@ public class TrekShellPlugin extends Plugin {
|
||||
}, CANCEL_GRACE_MS);
|
||||
}
|
||||
|
||||
private static final String AUTH_PREFS = "trek_auth_return";
|
||||
/** As long as the server keeps the code it carries (NATIVE_HANDOFF_CODE_TTL_MS), with room to spare. */
|
||||
private static final long ORPHAN_AUTH_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* A return to the sign-in scheme with no call waiting for it: Android ended
|
||||
* the app while the browser was in front, and the browser's return started
|
||||
* it anew (the launch intent comes through here as well). Kept on disk, as
|
||||
* the process may be new too, until the sign-in page takes it. The return
|
||||
* is then taken off the activity's intent: Capacitor hands the launch intent
|
||||
* in again whenever the activity is created, and Android does as well when
|
||||
* it rebuilds the task from the recent apps.
|
||||
*/
|
||||
private void keepOrphanAuthReturn(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data == null || !"auth".equals(data.getHost())) return;
|
||||
if (!getContext().getString(R.string.custom_url_scheme).equals(data.getScheme())) return;
|
||||
if (getActivity() != null && getActivity().getIntent() != null && getActivity().getIntent().getData() != null
|
||||
&& data.equals(getActivity().getIntent().getData())) {
|
||||
getActivity().setIntent(new Intent(getActivity().getIntent()).setData(null));
|
||||
}
|
||||
if ((intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return;
|
||||
getContext().getSharedPreferences(AUTH_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("url", data.toString())
|
||||
.putLong("at", System.currentTimeMillis())
|
||||
.commit();
|
||||
}
|
||||
|
||||
/** The kept return of a sign-in (keepOrphanAuthReturn), once and while fresh; null otherwise. */
|
||||
@PluginMethod
|
||||
public void takeAuthCallback(PluginCall call) {
|
||||
android.content.SharedPreferences prefs = getContext().getSharedPreferences(AUTH_PREFS, Context.MODE_PRIVATE);
|
||||
String url = prefs.getString("url", null);
|
||||
long at = prefs.getLong("at", 0);
|
||||
prefs.edit().clear().commit();
|
||||
boolean fresh = url != null && System.currentTimeMillis() - at <= ORPHAN_AUTH_TTL_MS;
|
||||
JSObject result = new JSObject();
|
||||
result.put("url", fresh ? url : JSONObject.NULL);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
private PluginCall pendingAuthCall() {
|
||||
return authCallId == null ? null : getBridge().getSavedCall(authCallId);
|
||||
}
|
||||
@@ -582,6 +733,12 @@ public class TrekShellPlugin extends Plugin {
|
||||
com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call);
|
||||
}
|
||||
|
||||
/** Whether this Google account already holds a delivered cloud subscription. */
|
||||
@PluginMethod
|
||||
public void storeHeld(PluginCall call) {
|
||||
com.liketrek.trek.billing.TrekBilling.INSTANCE.held(getContext(), call);
|
||||
}
|
||||
|
||||
/** A paid subscription never delivered (it cleared while the app was closed), for the shell to redeem. */
|
||||
@PluginMethod
|
||||
public void storeUnfinished(PluginCall call) {
|
||||
@@ -630,4 +787,129 @@ public class TrekShellPlugin extends Plugin {
|
||||
// The last layout WindowManager reported, posture and hinge included; the bare window before the first.
|
||||
getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.current(getActivity())));
|
||||
}
|
||||
|
||||
// ---- Client certificates (mutual TLS) ----
|
||||
|
||||
/**
|
||||
* The address screen's check of a server, made natively so it notices
|
||||
* whether the server asks for a client certificate and can show the stored
|
||||
* one (clientcert/ClientCertActions.request).
|
||||
*/
|
||||
@PluginMethod
|
||||
public void requestServer(PluginCall call) {
|
||||
String url = call.getString("url");
|
||||
Uri parsed = url == null ? null : Uri.parse(url);
|
||||
if (parsed == null || parsed.getHost() == null || !("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()))) {
|
||||
call.reject("A http(s) URL is required");
|
||||
return;
|
||||
}
|
||||
int timeout = Math.max(1000, Math.min(call.getInt("timeoutMs", 10000), 30000));
|
||||
new Thread(() -> {
|
||||
try {
|
||||
call.resolve(ClientCertActions.request(getContext(), url, timeout));
|
||||
} catch (Exception e) {
|
||||
call.reject(e.getMessage() == null ? "request failed" : e.getMessage());
|
||||
}
|
||||
}, "trek-request-server").start();
|
||||
}
|
||||
|
||||
/** The stored certificate's details, or none. */
|
||||
@PluginMethod
|
||||
public void clientCertificate(PluginCall call) {
|
||||
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
|
||||
JSObject result = new JSObject();
|
||||
result.put("certificate", info == null ? JSONObject.NULL : ClientCertActions.infoObject(info));
|
||||
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
|
||||
if (asked != null) {
|
||||
JSObject request = new JSObject();
|
||||
request.put("host", asked.getHost());
|
||||
request.put("port", asked.getPort());
|
||||
request.put("served", asked.getServed());
|
||||
result.put("request", request);
|
||||
}
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void removeClientCertificate(PluginCall call) {
|
||||
TrekClientIdentity.remove(getContext());
|
||||
forgetCertificateAnswers();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** The WebView keeps its answer to a certificate request per host; a changed certificate needs a fresh one. */
|
||||
private void forgetCertificateAnswers() {
|
||||
com.liketrek.trek.clientcert.ClientCertWatch.reset();
|
||||
getActivity().runOnUiThread(() -> android.webkit.WebView.clearClientCertPreferences(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a .p12/.pfx file, asks for its password until it opens or the user
|
||||
* gives up, and keeps it for the server at host and port. Resolves with
|
||||
* result "imported" (and the certificate), "cancelled", or "failed" and why.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void importClientCertificate(PluginCall call) {
|
||||
String host = call.getString("host");
|
||||
if (host == null || !host.matches("[A-Za-z0-9.-]+")) {
|
||||
call.reject("A host is required");
|
||||
return;
|
||||
}
|
||||
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*");
|
||||
pick.putExtra(Intent.EXTRA_MIME_TYPES, new String[] { "application/x-pkcs12", "application/pkcs12", "application/octet-stream" });
|
||||
startActivityForResult(call, pick, "clientCertificatePicked");
|
||||
}
|
||||
|
||||
@ActivityCallback
|
||||
private void clientCertificatePicked(PluginCall call, ActivityResult picked) {
|
||||
if (call == null) return;
|
||||
Uri uri = picked.getData() == null ? null : picked.getData().getData();
|
||||
if (picked.getResultCode() != android.app.Activity.RESULT_OK || uri == null) {
|
||||
resolveImport(call, "cancelled", null);
|
||||
return;
|
||||
}
|
||||
new Thread(() -> {
|
||||
byte[] bytes = ClientCertActions.readFile(getContext(), uri);
|
||||
if (bytes == null) resolveImport(call, "failed", TrekClientIdentity.Failure.FORMAT.name());
|
||||
else askPasswordFor(call, bytes, false);
|
||||
}, "trek-client-cert").start();
|
||||
}
|
||||
|
||||
private void askPasswordFor(PluginCall call, byte[] bytes, boolean wrong) {
|
||||
JSObject labels = call.getObject("labels", new JSObject());
|
||||
ClientCertActions.askPassword(getActivity(), labels, wrong, password -> {
|
||||
if (password == null) {
|
||||
resolveImport(call, "cancelled", null);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}
|
||||
new Thread(() -> openWith(call, bytes, password), "trek-client-cert").start();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void openWith(PluginCall call, byte[] bytes, char[] password) {
|
||||
try {
|
||||
TrekClientIdentity.Identity identity = TrekClientIdentity.read(bytes, password);
|
||||
TrekClientIdentity.save(getContext(), identity, call.getString("host"), call.getInt("port", 443));
|
||||
forgetCertificateAnswers();
|
||||
resolveImport(call, "imported", null);
|
||||
} catch (TrekClientIdentity.ImportException e) {
|
||||
if (e.getReason() == TrekClientIdentity.Failure.PASSWORD) askPasswordFor(call, bytes, true);
|
||||
else resolveImport(call, "failed", e.getReason().name());
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "client certificate not kept", e);
|
||||
resolveImport(call, "failed", "STORE");
|
||||
} finally {
|
||||
java.util.Arrays.fill(password, '\0');
|
||||
}
|
||||
}
|
||||
|
||||
private void resolveImport(PluginCall call, String result, String failure) {
|
||||
JSObject out = new JSObject();
|
||||
out.put("result", result);
|
||||
if (failure != null) out.put("failure", failure.toLowerCase(java.util.Locale.ROOT));
|
||||
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
|
||||
if ("imported".equals(result) && info != null) out.put("certificate", ClientCertActions.infoObject(info));
|
||||
call.resolve(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
import android.webkit.ClientCertRequest;
|
||||
import android.webkit.WebResourceError;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import com.getcapacitor.Bridge;
|
||||
import com.getcapacitor.BridgeWebViewClient;
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Used while the WebView shows the user's server. When that server cannot be
|
||||
@@ -18,10 +27,92 @@ import com.getcapacitor.BridgeWebViewClient;
|
||||
class TrekWebViewClient extends BridgeWebViewClient {
|
||||
|
||||
private final Runnable onUnreachable;
|
||||
/** Told whether the page now shown is a proxy's sign-in page, for the bar with the way out. */
|
||||
private final Consumer<Boolean> onSignInPage;
|
||||
/** Told whether the page now shown is an error page in place of the server's, for the same bar. */
|
||||
private final Consumer<Boolean> onErrorPage;
|
||||
/** The main-frame address that last came back as a 4xx page nobody else handled. */
|
||||
private String errorPageUrl;
|
||||
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;
|
||||
/** The page itself failed on the network once already; the next failure goes to the address screen. */
|
||||
private boolean retriedMainFrame;
|
||||
/** The page loaded again after a network error, until that load starts: its error page counts for nothing. */
|
||||
private String retryUrl;
|
||||
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) {
|
||||
TrekWebViewClient(
|
||||
Bridge bridge,
|
||||
Runnable onUnreachable,
|
||||
Consumer<Boolean> onSignInPage,
|
||||
Consumer<Boolean> onErrorPage,
|
||||
Set<String> signInHosts
|
||||
) {
|
||||
super(bridge);
|
||||
this.onUnreachable = onUnreachable;
|
||||
this.onSignInPage = onSignInPage;
|
||||
this.onErrorPage = onErrorPage;
|
||||
this.signInHosts = new HashSet<>(signInHosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sign-in page of a proxy in front of the server loads here rather than
|
||||
* in the browser, so its session cookie is the web view's and every request
|
||||
* of the app carries it, as in a browser. Only the hosts the address screen
|
||||
* saw that proxy send it to (TrekServerStore.getSignInHosts), and only as a
|
||||
* page: Capacitor injects its bridge into the server's origin alone, so the
|
||||
* proxy's page gets no access to the app. Everything else goes on to
|
||||
* Capacitor, which opens a foreign page in the browser.
|
||||
*/
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||
if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false;
|
||||
String learned = SignInRedirect.learnedHost(
|
||||
TrekServerStore.getUrl(view.getContext()),
|
||||
request.getUrl().toString(),
|
||||
request.isForMainFrame(),
|
||||
request.isRedirect(),
|
||||
serverShown
|
||||
);
|
||||
if (learned != null) {
|
||||
signInHosts.add(learned);
|
||||
TrekServerStore.addSignInHost(view.getContext(), learned);
|
||||
return false;
|
||||
}
|
||||
return super.shouldOverrideUrlLoading(view, request);
|
||||
}
|
||||
|
||||
/** Every page the main frame shows, pushState included: is it a proxy's sign-in page? */
|
||||
@Override
|
||||
public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) {
|
||||
super.doUpdateVisitedHistory(view, url, isReload);
|
||||
if (retryUrl != null) return;
|
||||
onSignInPage.accept(url != null && isSignInHost(Uri.parse(url)));
|
||||
// The 4xx answer arrives before this commit or after it; either way the
|
||||
// flag is read once here, so the next page clears the bar again.
|
||||
boolean error = url != null && url.equals(errorPageUrl);
|
||||
errorPageUrl = null;
|
||||
onErrorPage.accept(error);
|
||||
}
|
||||
|
||||
/** After the server's first page, a pending welcome code opens the welcome page once (TrekServerStore.takeWelcomeUrl). */
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
super.onPageFinished(view, url);
|
||||
// The error page of a load that is about to be retried: not the server's
|
||||
// first page, so no welcome page and no learning of sign-in hosts yet.
|
||||
if (retryUrl != null) return;
|
||||
if (SignInRedirect.onServer(TrekServerStore.getUrl(view.getContext()), url)) serverShown = true;
|
||||
String welcome = TrekServerStore.takeWelcomeUrl(view.getContext());
|
||||
if (welcome != null) view.loadUrl(welcome);
|
||||
}
|
||||
|
||||
private boolean isSignInHost(Uri url) {
|
||||
String scheme = url.getScheme();
|
||||
String host = url.getHost();
|
||||
if (host == null || !("https".equals(scheme) || "http".equals(scheme))) return false;
|
||||
String key = host.toLowerCase(Locale.ROOT);
|
||||
return signInHosts.contains(key) || (url.getPort() != -1 && signInHosts.contains(key + ":" + url.getPort()));
|
||||
}
|
||||
|
||||
/** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */
|
||||
@@ -34,9 +125,53 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
return super.shouldInterceptRequest(view, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* A proxy in front of the server asks for a client certificate (mutual TLS).
|
||||
* The stored one goes only to the host and port it was imported for; every
|
||||
* other site that asks is told there is none, as a browser without a
|
||||
* matching certificate would. The WebView remembers either answer for the
|
||||
* host until the app restarts, which a new certificate always brings along.
|
||||
*/
|
||||
@Override
|
||||
public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) {
|
||||
TrekClientIdentity.Identity identity = TrekClientIdentity.identityFor(view.getContext(), request.getHost(), request.getPort());
|
||||
TrekClientIdentity.noteRequest(request.getHost(), request.getPort(), identity != null);
|
||||
if (identity != null) request.proceed(identity.getKey(), identity.getChain());
|
||||
else request.ignore();
|
||||
// The page may still come from the service worker's cache while every
|
||||
// request fails: check natively whether the server lets the app in.
|
||||
String server = TrekServerStore.getUrl(view.getContext());
|
||||
Uri serverUri = server == null ? null : Uri.parse(server);
|
||||
if (serverUri != null && TrekClientIdentity.matches(String.valueOf(serverUri.getHost()), serverUri.getPort() > 0 ? serverUri.getPort() : 443, request.getHost(), request.getPort())) {
|
||||
com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, () -> {
|
||||
TrekServerStore.setFailure(view.getContext(), "client certificate requested");
|
||||
onUnreachable.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page that failed on the network is loaded once more before the app says
|
||||
* the server is unreachable. Started from the background, as when the
|
||||
* browser hands a sign-in back to an app Android had ended, a Samsung phone
|
||||
* may hold its network back for a moment, and the first load fails with the
|
||||
* server there all along. The error is logged, as a report has nothing else.
|
||||
*/
|
||||
@Override
|
||||
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||
if (request.isForMainFrame()) {
|
||||
Log.w("TrekShell", "Page failed: " + error.getErrorCode() + " " + error.getDescription());
|
||||
if (!retriedMainFrame && isNetworkError(error.getErrorCode())) {
|
||||
retriedMainFrame = true;
|
||||
retryUrl = request.getUrl().toString();
|
||||
view.postDelayed(() -> {
|
||||
String url = retryUrl;
|
||||
retryUrl = null;
|
||||
view.loadUrl(url);
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
TrekServerStore.setFailure(view.getContext(), error.getDescription() + " (" + error.getErrorCode() + ")");
|
||||
onUnreachable.run();
|
||||
return;
|
||||
}
|
||||
@@ -45,15 +180,37 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
|
||||
/**
|
||||
* Only a 5xx on the page itself means the server is down (a proxy's 502 or
|
||||
* 503 while TREK restarts). A 4xx page is left alone: the server answered,
|
||||
* and TREK serves its UI for every other path.
|
||||
* 503 while TREK restarts). A 4xx page is not TREK's, which serves its UI for
|
||||
* every path: a proxy that asked this page for a client certificate and got
|
||||
* none, as Cloudflare does with a 403, sends the app to the address screen,
|
||||
* which says so and offers to add one. Any other 4xx page (a proxy's block
|
||||
* page, a certificate it refused) stays up with the bar and its way out, so
|
||||
* the app is never stuck on a page without TREK's UI.
|
||||
*/
|
||||
@Override
|
||||
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
|
||||
int status = errorResponse.getStatusCode();
|
||||
if (!request.isForMainFrame()) {
|
||||
super.onReceivedHttpError(view, request, errorResponse);
|
||||
} else if (errorResponse.getStatusCode() >= 500) {
|
||||
} else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) {
|
||||
TrekServerStore.setFailure(view.getContext(), "HTTP " + status);
|
||||
onUnreachable.run();
|
||||
} else if (status >= 400) {
|
||||
errorPageUrl = request.getUrl().toString();
|
||||
onErrorPage.accept(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isNetworkError(int code) {
|
||||
return code == WebViewClient.ERROR_HOST_LOOKUP
|
||||
|| code == WebViewClient.ERROR_CONNECT
|
||||
|| code == WebViewClient.ERROR_TIMEOUT
|
||||
|| code == WebViewClient.ERROR_IO
|
||||
|| code == WebViewClient.ERROR_UNKNOWN;
|
||||
}
|
||||
|
||||
private static boolean wantedCertificate(Uri url) {
|
||||
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
|
||||
return asked != null && !asked.getServed() && TrekClientIdentity.matches(asked.getHost(), asked.getPort(), url.getHost(), url.getPort());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,23 +119,38 @@ object TrekBilling {
|
||||
val id = call.getString("productId") ?: return call.reject("productId is required")
|
||||
ready(activity) { ok ->
|
||||
if (!ok) return@ready call.resolve(JSObject().put("kind", "failed"))
|
||||
details(activity, listOf(id)) { found ->
|
||||
val product = found.firstOrNull()
|
||||
val offer = product?.let(::baseOffer)
|
||||
if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed"))
|
||||
pendingPurchase?.resolve(JSObject().put("kind", "cancelled"))
|
||||
pendingPurchase = call
|
||||
pendingProduct = id
|
||||
val params = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(
|
||||
listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()),
|
||||
)
|
||||
.build()
|
||||
activity.runOnUiThread {
|
||||
val launch = client(activity).launchBillingFlow(activity, params)
|
||||
if (launch.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
settle(if (launch.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) "cancelled" else "failed", null)
|
||||
}
|
||||
// One subscription per Google account: a second plan would run beside the first.
|
||||
// A delivered one stops the purchase here; one that was paid but never set up is
|
||||
// handed back as bought, so its instance is set up now.
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
|
||||
client(activity).queryPurchasesAsync(params) { result, purchases ->
|
||||
val held = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, PREFIX) else null
|
||||
when {
|
||||
held == null -> launchPurchase(activity, call, id)
|
||||
held.isAcknowledged -> call.resolve(JSObject().put("kind", "subscribed"))
|
||||
else -> call.resolve(receipt(held).put("kind", "bought"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchPurchase(activity: Activity, call: PluginCall, id: String) {
|
||||
details(activity, listOf(id)) { found ->
|
||||
val product = found.firstOrNull()
|
||||
val offer = product?.let(::baseOffer)
|
||||
if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed"))
|
||||
pendingPurchase?.resolve(JSObject().put("kind", "cancelled"))
|
||||
pendingPurchase = call
|
||||
pendingProduct = id
|
||||
val params = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(
|
||||
listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()),
|
||||
)
|
||||
.build()
|
||||
activity.runOnUiThread {
|
||||
val launch = client(activity).launchBillingFlow(activity, params)
|
||||
if (launch.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
settle(if (launch.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) "cancelled" else "failed", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +223,19 @@ object TrekBilling {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this Google account holds a delivered subscription under `prefix`; while one runs, the shell offers no purchase. */
|
||||
fun held(context: Context, call: PluginCall) {
|
||||
val prefix = call.getString("prefix") ?: PREFIX
|
||||
ready(context) { ok ->
|
||||
if (!ok) return@ready call.resolve(JSObject().put("held", false))
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
|
||||
client(context).queryPurchasesAsync(params) { result, purchases ->
|
||||
val found = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, prefix) else null
|
||||
call.resolve(JSObject().put("held", found?.isAcknowledged == true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest active subscription under `prefix` on this Google account, if any. */
|
||||
fun restore(context: Context, call: PluginCall) {
|
||||
val prefix = call.getString("prefix") ?: "cloud."
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.liketrek.trek.clientcert
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.text.InputType
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import com.getcapacitor.JSObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
||||
/**
|
||||
* What TrekShellPlugin does for client certificates (TrekClientIdentity):
|
||||
* check a server while noticing whether it asks for one, read a picked file,
|
||||
* and ask for its password.
|
||||
*/
|
||||
object ClientCertActions {
|
||||
|
||||
private const val MAX_BODY_BYTES = 512 * 1024
|
||||
private const val MAX_FILE_BYTES = 256 * 1024
|
||||
private const val MAX_REDIRECTS = 5
|
||||
|
||||
/**
|
||||
* A GET against the user's server for the address screen: status, body, final
|
||||
* URL and headers, plus whether the server asked for a client certificate and
|
||||
* whether one was shown. A failed connection answers status 0 instead of
|
||||
* throwing, so a handshake the server broke off for want of a certificate
|
||||
* still reports that it asked.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun request(context: Context, url: String, timeoutMs: Int): JSObject {
|
||||
var asked: Triple<String, Int, Boolean>? = null
|
||||
val factory = TrekClientIdentity.socketFactory(context) { host, port, served -> asked = Triple(host, port, served) }
|
||||
val result = JSObject()
|
||||
var current = URL(url)
|
||||
try {
|
||||
for (hop in 0..MAX_REDIRECTS) {
|
||||
val connection = current.openConnection() as HttpURLConnection
|
||||
if (connection is HttpsURLConnection) connection.sslSocketFactory = factory
|
||||
connection.instanceFollowRedirects = false
|
||||
connection.connectTimeout = timeoutMs
|
||||
connection.readTimeout = timeoutMs
|
||||
connection.setRequestProperty("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
|
||||
try {
|
||||
val status = connection.responseCode
|
||||
val location = connection.getHeaderField("Location")
|
||||
if (status in 300..399 && location != null && hop < MAX_REDIRECTS) {
|
||||
current = URL(current, location)
|
||||
continue
|
||||
}
|
||||
val stream = if (status >= 400) connection.errorStream else connection.inputStream
|
||||
result.put("status", status)
|
||||
result.put("data", stream?.use { readCapped(it, MAX_BODY_BYTES) }?.toString(Charsets.UTF_8) ?: "")
|
||||
result.put("url", current.toString())
|
||||
val headers = JSObject()
|
||||
for ((name, values) in connection.headerFields) if (name != null && values.isNotEmpty()) headers.put(name, values.first())
|
||||
result.put("headers", headers)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
break
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
result.put("status", 0)
|
||||
result.put("data", "")
|
||||
result.put("url", current.toString())
|
||||
result.put("error", e.javaClass.simpleName)
|
||||
}
|
||||
val seen = asked
|
||||
result.put("certRequested", seen != null)
|
||||
result.put("certSent", seen?.third == true)
|
||||
if (seen != null) result.put("certHost", "${seen.first}:${seen.second}")
|
||||
return result
|
||||
}
|
||||
|
||||
/** The bytes of a picked file, or null when it cannot be read or is far too big for a certificate. */
|
||||
@JvmStatic
|
||||
fun readFile(context: Context, uri: Uri): ByteArray? = try {
|
||||
context.contentResolver.openInputStream(uri)?.use { readCapped(it, MAX_FILE_BYTES + 1) }?.takeIf { it.size <= MAX_FILE_BYTES }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* The password prompt, with the UI's own words (`labels`: title, message,
|
||||
* placeholder, ok, cancel, wrongPassword). `wrong` shows that the last try
|
||||
* did not open the file. `done` gets the password, or null when cancelled.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun askPassword(activity: Activity, labels: JSObject, wrong: Boolean, done: (CharArray?) -> Unit) {
|
||||
activity.runOnUiThread {
|
||||
val input = EditText(activity).apply {
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
hint = labels.getString("placeholder") ?: ""
|
||||
}
|
||||
val frame = FrameLayout(activity).apply {
|
||||
val pad = (20 * activity.resources.displayMetrics.density).toInt()
|
||||
setPadding(pad, pad / 2, pad, 0)
|
||||
addView(input, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||
}
|
||||
val message = if (wrong) labels.getString("wrongPassword") else labels.getString("message")
|
||||
var answered = false
|
||||
val dialog = AlertDialog.Builder(activity)
|
||||
.setTitle(labels.getString("title"))
|
||||
.setMessage(message)
|
||||
.setView(frame)
|
||||
.setPositiveButton(labels.getString("ok") ?: "OK") { _, _ ->
|
||||
answered = true
|
||||
val text = input.text
|
||||
val password = CharArray(text.length) { text[it] }
|
||||
done(password)
|
||||
}
|
||||
.setNegativeButton(labels.getString("cancel") ?: "Cancel") { _, _ ->
|
||||
answered = true
|
||||
done(null)
|
||||
}
|
||||
.setOnDismissListener { if (!answered) done(null) }
|
||||
.create()
|
||||
dialog.show()
|
||||
input.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
/** The settings' view of the stored certificate. */
|
||||
@JvmStatic
|
||||
fun infoObject(info: TrekClientIdentity.Info): JSObject = JSObject().apply {
|
||||
put("host", info.host)
|
||||
put("port", info.port)
|
||||
put("subject", info.subject)
|
||||
put("issuer", info.issuer)
|
||||
put("notAfter", info.notAfter)
|
||||
}
|
||||
|
||||
private fun readCapped(stream: InputStream, max: Int): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8192)
|
||||
while (out.size() < max) {
|
||||
val n = stream.read(buffer, 0, minOf(buffer.size, max - out.size()))
|
||||
if (n < 0) break
|
||||
out.write(buffer, 0, n)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.liketrek.trek.clientcert
|
||||
|
||||
import android.content.Context
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Notices when the user's server stops letting the app in for want of a
|
||||
* client certificate: one removed, expired, or no longer trusted by the proxy.
|
||||
*
|
||||
* The page alone does not show it. The service worker serves TREK's start page
|
||||
* from its cache, so the page loads and only its requests fail, and the UI
|
||||
* reports a server it cannot reach with no way to add a certificate. So when
|
||||
* the server asks for one, the app checks once natively whether TREK answers
|
||||
* with what it holds. Only a check that fails at the certificate (the handshake
|
||||
* broke off, or the proxy answered with its own 4xx) sends the app to the
|
||||
* address screen, which says so and offers to add one. A server that merely
|
||||
* asks and lets everyone in answers TREK's 200, and nothing happens.
|
||||
*/
|
||||
object ClientCertWatch {
|
||||
|
||||
private const val MIN_INTERVAL_MS = 60_000L
|
||||
private const val FEATURES_PATH = "/api/health/features"
|
||||
private const val TIMEOUT_MS = 8000
|
||||
|
||||
private val lastCheck = AtomicLong(0)
|
||||
|
||||
/** Called where the server asked for a certificate; `onRefused` runs at most once a minute, on a background thread. */
|
||||
@JvmStatic
|
||||
fun serverAsked(context: Context, serverUrl: String?, onRefused: Runnable) {
|
||||
if (serverUrl == null) return
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastCheck.get()
|
||||
if (now - last < MIN_INTERVAL_MS || !lastCheck.compareAndSet(last, now)) return
|
||||
val appContext = context.applicationContext
|
||||
Thread({
|
||||
val result = ClientCertActions.request(appContext, serverUrl.trimEnd('/') + FEATURES_PATH, TIMEOUT_MS)
|
||||
if (refused(result.optBoolean("certRequested"), result.optInt("status"))) onRefused.run()
|
||||
}, "trek-client-cert-watch").start()
|
||||
}
|
||||
|
||||
/** A new or removed certificate is worth a check of its own, whenever the last one ran. */
|
||||
@JvmStatic
|
||||
fun reset() {
|
||||
lastCheck.set(0)
|
||||
}
|
||||
|
||||
/** The server asked for a certificate and did not answer with TREK. */
|
||||
@JvmStatic
|
||||
fun refused(certRequested: Boolean, status: Int): Boolean = certRequested && (status == 0 || status in 400..499)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.liketrek.trek.clientcert
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.net.Socket
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.security.PrivateKey
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
|
||||
/**
|
||||
* The client certificate a user's TREK server asks for (mutual TLS): a proxy
|
||||
* such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through only
|
||||
* devices that show one. The app holds at most one, for one server.
|
||||
*
|
||||
* It comes in as a PKCS#12 file (.p12, .pfx) and its password. Neither is kept:
|
||||
* the private key is sealed with an AES key of the Android keystore, which
|
||||
* never leaves it, and stored with the certificate chain in the app's private
|
||||
* preferences. In memory the key is an ordinary one, which TLS 1.2 and 1.3 and
|
||||
* the WebView all take, whatever its kind; a key imported into the keystore
|
||||
* itself would be refused by some of them for its padding or digest rules.
|
||||
*
|
||||
* Only the host and port it was imported for ever see it (`identityFor`), so a
|
||||
* login page, a map tile provider or liketrek.com never learns of it.
|
||||
*/
|
||||
object TrekClientIdentity {
|
||||
|
||||
private const val PREFS = "trek_client_identity"
|
||||
private const val WRAP_ALIAS = "trek-client-identity-wrap"
|
||||
private const val KEYMANAGER_ALIAS = "trek-client-identity"
|
||||
private const val GCM_TAG_BITS = 128
|
||||
|
||||
/** Why a file could not be taken. */
|
||||
enum class Failure { PASSWORD, FORMAT, NO_KEY, EXPIRED, NOT_YET_VALID }
|
||||
|
||||
class ImportException(val reason: Failure) : Exception(reason.name)
|
||||
|
||||
class Identity(val key: PrivateKey, val chain: Array<X509Certificate>)
|
||||
|
||||
/** What the settings show: who the certificate names, who issued it, until when, and for which server. */
|
||||
data class Info(val host: String, val port: Int, val subject: String, val issuer: String, val notAfter: Long)
|
||||
|
||||
/** The opened identity, with the sealed key text it was opened from: opened once, not per request. */
|
||||
@Volatile private var cached: Pair<String, Identity>? = null
|
||||
|
||||
/** The last time a server asked for a certificate while the app showed it: for the address screen. */
|
||||
@Volatile var lastRequest: Request? = null
|
||||
private set
|
||||
|
||||
data class Request(val host: String, val port: Int, val served: Boolean)
|
||||
|
||||
private class Stored(val host: String, val port: Int, val algorithm: String, val sealedText: String, val iv: ByteArray, val chain: List<ByteArray>)
|
||||
|
||||
/**
|
||||
* Reads a PKCS#12 file: the first entry that holds a private key, with its chain.
|
||||
* Throws ImportException naming what is wrong with it.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
@Throws(ImportException::class)
|
||||
fun read(bytes: ByteArray, password: CharArray, now: Date = Date()): Identity {
|
||||
val store = KeyStore.getInstance("PKCS12")
|
||||
try {
|
||||
store.load(ByteArrayInputStream(bytes), password)
|
||||
} catch (e: IOException) {
|
||||
throw ImportException(if (isPasswordFailure(e)) Failure.PASSWORD else Failure.FORMAT)
|
||||
} catch (e: Exception) {
|
||||
throw ImportException(Failure.FORMAT)
|
||||
}
|
||||
val alias = store.aliases().toList().firstOrNull { store.isKeyEntry(it) } ?: throw ImportException(Failure.NO_KEY)
|
||||
val key = try {
|
||||
store.getKey(alias, password) as? PrivateKey
|
||||
} catch (e: UnrecoverableKeyException) {
|
||||
throw ImportException(Failure.PASSWORD)
|
||||
} ?: throw ImportException(Failure.NO_KEY)
|
||||
val chain = store.getCertificateChain(alias)?.mapNotNull { it as? X509Certificate }?.toTypedArray()
|
||||
if (chain.isNullOrEmpty()) throw ImportException(Failure.NO_KEY)
|
||||
val leaf = chain[0]
|
||||
if (now.after(leaf.notAfter)) throw ImportException(Failure.EXPIRED)
|
||||
if (now.before(leaf.notBefore)) throw ImportException(Failure.NOT_YET_VALID)
|
||||
return Identity(key, chain)
|
||||
}
|
||||
|
||||
/** A wrong password shows up as a failed MAC check or an unrecoverable key, wrapped in an IOException. */
|
||||
private fun isPasswordFailure(e: Throwable): Boolean {
|
||||
var cause: Throwable? = e
|
||||
while (cause != null) {
|
||||
if (cause is UnrecoverableKeyException || cause is javax.crypto.BadPaddingException) return true
|
||||
if (cause.message?.lowercase(Locale.ROOT)?.contains("password") == true) return true
|
||||
cause = cause.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Keeps the identity for this host and port, replacing any other. */
|
||||
@JvmStatic
|
||||
fun save(context: Context, identity: Identity, host: String, port: Int) {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, wrapKey())
|
||||
val sealed = cipher.doFinal(identity.key.encoded)
|
||||
prefs(context).edit()
|
||||
.putString("host", host.lowercase(Locale.ROOT))
|
||||
.putInt("port", port)
|
||||
.putString("algorithm", identity.key.algorithm)
|
||||
.putString("key", encode(sealed))
|
||||
.putString("iv", encode(cipher.iv))
|
||||
.putString("chain", identity.chain.joinToString(",") { encode(it.encoded) })
|
||||
.apply()
|
||||
cached = null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun remove(context: Context) {
|
||||
prefs(context).edit().clear().apply()
|
||||
cached = null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun info(context: Context): Info? {
|
||||
val stored = stored(context) ?: return null
|
||||
val leaf = runCatching { certificate(stored.chain.first()) }.getOrNull() ?: return null
|
||||
return Info(stored.host, stored.port, commonName(leaf.subjectX500Principal), commonName(leaf.issuerX500Principal), leaf.notAfter.time)
|
||||
}
|
||||
|
||||
/** The identity for a server at this host and port, or null when there is none for it. */
|
||||
@JvmStatic
|
||||
fun identityFor(context: Context, host: String?, port: Int): Identity? {
|
||||
val stored = stored(context) ?: return null
|
||||
if (!matches(stored.host, stored.port, host, port)) return null
|
||||
return opened(stored)
|
||||
}
|
||||
|
||||
private fun opened(stored: Stored): Identity? {
|
||||
cached?.let { (from, identity) -> if (from == stored.sealedText) return identity }
|
||||
val identity = runCatching { open(stored) }.getOrNull() ?: return null
|
||||
cached = stored.sealedText to identity
|
||||
return identity
|
||||
}
|
||||
|
||||
/** Whether a request to host and port (-1 or 0 for the scheme's default) goes to the server the identity is for. */
|
||||
@JvmStatic
|
||||
fun matches(storedHost: String, storedPort: Int, host: String?, port: Int): Boolean {
|
||||
if (host == null || !host.equals(storedHost, ignoreCase = true)) return false
|
||||
val effective = if (port <= 0) 443 else port
|
||||
return effective == storedPort
|
||||
}
|
||||
|
||||
/** Called where a server asked for a certificate, so the address screen can offer one. */
|
||||
@JvmStatic
|
||||
fun noteRequest(host: String?, port: Int, served: Boolean) {
|
||||
if (host == null) return
|
||||
lastRequest = Request(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, served)
|
||||
}
|
||||
|
||||
/**
|
||||
* A socket factory for the app's own requests (the server check, map tiles).
|
||||
* It offers the identity only when the server it is talking to asks for one
|
||||
* and is the one the identity is for, and tells `onAsked` about every ask.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun socketFactory(context: Context, onAsked: ((host: String, port: Int, served: Boolean) -> Unit)? = null): SSLSocketFactory {
|
||||
val appContext = context.applicationContext
|
||||
val manager = object : X509ExtendedKeyManager() {
|
||||
// Decided per handshake in choose(); the key and chain are then read for
|
||||
// the one alias, so parallel connections never see each other's choice.
|
||||
private fun choose(host: String?, port: Int): String? {
|
||||
val identity = identityFor(appContext, host, port)
|
||||
if (host != null) {
|
||||
noteRequest(host, port, identity != null)
|
||||
onAsked?.invoke(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, identity != null)
|
||||
}
|
||||
return if (identity != null) KEYMANAGER_ALIAS else null
|
||||
}
|
||||
|
||||
override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? {
|
||||
val session = (socket as? SSLSocket)?.handshakeSession
|
||||
return choose(session?.peerHost, session?.peerPort ?: -1)
|
||||
}
|
||||
|
||||
override fun chooseEngineClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, engine: SSLEngine?): String? =
|
||||
choose(engine?.peerHost, engine?.peerPort ?: -1)
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? =
|
||||
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.chain else null
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? =
|
||||
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.key else null
|
||||
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
|
||||
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
|
||||
override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String? = null
|
||||
}
|
||||
val tls = SSLContext.getInstance("TLS")
|
||||
tls.init(arrayOf(manager), null, null)
|
||||
return tls.socketFactory
|
||||
}
|
||||
|
||||
private fun open(stored: Stored): Identity {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), GCMParameterSpec(GCM_TAG_BITS, stored.iv))
|
||||
val key = KeyFactory.getInstance(stored.algorithm).generatePrivate(PKCS8EncodedKeySpec(cipher.doFinal(decode(stored.sealedText))))
|
||||
return Identity(key, stored.chain.map { certificate(it) }.toTypedArray())
|
||||
}
|
||||
|
||||
private fun stored(context: Context): Stored? {
|
||||
val p = prefs(context)
|
||||
val host = p.getString("host", null) ?: return null
|
||||
val algorithm = p.getString("algorithm", null) ?: return null
|
||||
val key = p.getString("key", null) ?: return null
|
||||
val iv = p.getString("iv", null) ?: return null
|
||||
val chain = p.getString("chain", null)?.split(",")?.filter { it.isNotEmpty() }?.map { decode(it) } ?: return null
|
||||
if (chain.isEmpty()) return null
|
||||
return Stored(host, p.getInt("port", 443), algorithm, key, decode(iv), chain)
|
||||
}
|
||||
|
||||
/** The keystore's AES key that seals the private key; made once, never leaves the keystore. */
|
||||
private fun wrapKey(): SecretKey {
|
||||
val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(keystore.getKey(WRAP_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
private fun certificate(der: ByteArray): X509Certificate =
|
||||
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(der)) as X509Certificate
|
||||
|
||||
/** The CN of a name, or the whole name when it has none. */
|
||||
@JvmStatic
|
||||
fun commonName(principal: javax.security.auth.x500.X500Principal): String {
|
||||
val name = principal.getName(javax.security.auth.x500.X500Principal.RFC2253)
|
||||
return Regex("(?:^|,)CN=((?:\\\\.|[^,])*)").find(name)?.groupValues?.get(1)?.replace("\\", "") ?: name
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun encode(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
private fun decode(text: String) = Base64.decode(text, Base64.NO_WRAP)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ object ShareHandoff {
|
||||
fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching {
|
||||
val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType)
|
||||
if (tripId != null) meta.put("tripId", tripId)
|
||||
if (item.url != null) meta.put("url", item.url).put("title", item.title)
|
||||
File(dir(context), "pending.data").writeBytes(item.data)
|
||||
File(dir(context), "pending.json").writeText(meta.toString())
|
||||
true
|
||||
|
||||
@@ -26,6 +26,11 @@ data class ShareItem(
|
||||
val title: String,
|
||||
/** A host, for a page. */
|
||||
val source: String? = null,
|
||||
/**
|
||||
* The web address it came from, when it is a page or a link: the app offers to
|
||||
* keep it among the trip's links as well as to read a booking out of it.
|
||||
*/
|
||||
val url: String? = null,
|
||||
) {
|
||||
enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE }
|
||||
|
||||
@@ -90,11 +95,16 @@ object ShareLoader {
|
||||
return checked(ShareItem(kind, withExtension(name, kind), normalized, data, name.substringBeforeLast('.').ifBlank { name }))
|
||||
}
|
||||
|
||||
/** A link becomes the page it points at; anything else, a text file the import reads. */
|
||||
/**
|
||||
* A link becomes the page it points at, or the bare link when the page cannot be
|
||||
* read (a sign-in wall, an app-only page, no network), which the app can still
|
||||
* keep among the trip's links. Anything else, a text file the import reads.
|
||||
*/
|
||||
internal fun fromText(text: String, subject: String?): ShareItem {
|
||||
val link = firstLink(text)
|
||||
if (link != null) {
|
||||
runCatching { return page(link, subject) }
|
||||
return linkOnly(link, subject)
|
||||
}
|
||||
val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: text
|
||||
return checked(ShareItem(ShareItem.Kind.TEXT, "shared-text.txt", "text/plain", text.toByteArray(), (subject ?: firstLine).take(80)))
|
||||
@@ -121,12 +131,19 @@ object ShareLoader {
|
||||
val html = String(data, Charsets.UTF_8)
|
||||
val host = runCatching { Uri.parse(link).host }.getOrNull()
|
||||
val title = pageTitle(html) ?: subject ?: host ?: "Page"
|
||||
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host))
|
||||
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host, link))
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** The link alone, as a text/uri-list the app recognises as nothing to read a booking from. */
|
||||
internal fun linkOnly(link: String, subject: String?): ShareItem {
|
||||
// java.net.URI rather than android.net.Uri: the same host, and readable in a JVM unit test.
|
||||
val host = runCatching { java.net.URI(link).host }.getOrNull()
|
||||
return ShareItem(ShareItem.Kind.PAGE, "link.url", "text/uri-list", link.toByteArray(), subject ?: host ?: link, host, link)
|
||||
}
|
||||
|
||||
/** The kind and the MIME type the import should see, from the name when the type is vague. */
|
||||
internal fun classify(name: String, mime: String): Pair<ShareItem.Kind, String> {
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.liketrek.trek
|
||||
|
||||
import com.liketrek.trek.clientcert.ClientCertWatch
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity
|
||||
import com.liketrek.trek.clientcert.TrekClientIdentity.Failure
|
||||
import java.util.Date
|
||||
import javax.security.auth.x500.X500Principal
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Reading a client certificate file (TrekClientIdentity.read) and the rule for
|
||||
* which server sees it. The files in test/resources/clientcert are test-only:
|
||||
* a throwaway CA, password "trek-test". valid.p12 has OpenSSL 3's default
|
||||
* encryption (AES-256, PBKDF2), valid-legacy.p12 the older RC2/3DES one that
|
||||
* many routers and NAS systems still export.
|
||||
*/
|
||||
class ClientIdentityTest {
|
||||
private val password = "trek-test".toCharArray()
|
||||
private val inside = Date(1_800_000_000_000L) // 2027-01-15, inside every valid test certificate
|
||||
|
||||
private fun file(name: String): ByteArray =
|
||||
requireNotNull(javaClass.classLoader?.getResourceAsStream("clientcert/$name")) { name }.use { it.readBytes() }
|
||||
|
||||
private fun failure(name: String, pass: CharArray = password, now: Date = inside): Failure {
|
||||
try {
|
||||
TrekClientIdentity.read(file(name), pass, now)
|
||||
} catch (e: TrekClientIdentity.ImportException) {
|
||||
return e.reason
|
||||
}
|
||||
fail("$name opened")
|
||||
throw AssertionError()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opensModernAndLegacyFiles() {
|
||||
for (name in listOf("valid.p12", "valid-legacy.p12")) {
|
||||
val identity = TrekClientIdentity.read(file(name), password, inside)
|
||||
assertEquals("trek-test-valid", TrekClientIdentity.commonName(identity.chain[0].subjectX500Principal))
|
||||
assertEquals("TREK mTLS Test CA", TrekClientIdentity.commonName(identity.chain[0].issuerX500Principal))
|
||||
assertEquals("RSA", identity.key.algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun namesWhatIsWrongWithAFile() {
|
||||
assertEquals(Failure.PASSWORD, failure("valid.p12", "wrong".toCharArray()))
|
||||
assertEquals(Failure.PASSWORD, failure("valid-legacy.p12", "wrong".toCharArray()))
|
||||
assertEquals(Failure.EXPIRED, failure("expired.p12"))
|
||||
assertEquals(Failure.NOT_YET_VALID, failure("future.p12"))
|
||||
assertEquals(Failure.NO_KEY, failure("cert-only.p12"))
|
||||
try {
|
||||
TrekClientIdentity.read("not a certificate".toByteArray(), password, inside)
|
||||
fail("text opened")
|
||||
} catch (e: TrekClientIdentity.ImportException) {
|
||||
assertEquals(Failure.FORMAT, e.reason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsTheCertificateOnlyToItsHostAndPort() {
|
||||
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "Trek.Example.com", -1))
|
||||
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 443))
|
||||
assertTrue(TrekClientIdentity.matches("nas.local", 8443, "nas.local", 8443))
|
||||
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "auth.example.com", 443))
|
||||
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 8443))
|
||||
assertFalse(TrekClientIdentity.matches("nas.local", 8443, "nas.local", -1))
|
||||
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, null, 443))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesForTheAddressScreenOnlyWhenTheCertificateIsWhatFails() {
|
||||
assertTrue(ClientCertWatch.refused(true, 0)) // handshake broken off: nginx, Caddy, Traefik
|
||||
assertTrue(ClientCertWatch.refused(true, 403)) // Cloudflare's block page
|
||||
assertTrue(ClientCertWatch.refused(true, 400)) // nginx without a valid certificate
|
||||
assertFalse(ClientCertWatch.refused(true, 200)) // asks, but lets everyone in
|
||||
assertFalse(ClientCertWatch.refused(true, 502)) // TREK restarting behind the proxy
|
||||
assertFalse(ClientCertWatch.refused(false, 0)) // simply offline
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsTheCommonNameOrFallsBackToTheWholeName() {
|
||||
assertEquals("Phone, home", TrekClientIdentity.commonName(X500Principal("CN=Phone\\, home,O=Family")))
|
||||
assertEquals("O=Family", TrekClientIdentity.commonName(X500Principal("O=Family")))
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,33 @@ import java.time.LocalDate
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** The native side's own decisions, apart from the views. */
|
||||
class NativeLogicTest {
|
||||
@Test
|
||||
fun aRedirectAwayWhileTheServerLoadsIsTakenAsItsSignIn() {
|
||||
val server = "https://trek.example.com"
|
||||
// Authelia in front of the page, its check let through on /api.
|
||||
assertEquals("auth.example.com", SignInRedirect.learnedHost(server, "https://auth.example.com/?rd=https%3A%2F%2Ftrek.example.com%2F", true, true, false))
|
||||
assertEquals("auth.example.com:9091", SignInRedirect.learnedHost(server, "https://Auth.example.com:9091/", true, true, false))
|
||||
// A link the user follows, a frame, or anything after the server's page has shown: the browser, as before.
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, false, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", false, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, true, true))
|
||||
// The server's own host, another scheme, or a step down to plain http.
|
||||
assertNull(SignInRedirect.learnedHost(server, "https://trek.example.com/login", true, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "intent://auth.example.com/", true, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(server, "http://auth.example.com/", true, true, false))
|
||||
assertEquals("auth.lan", SignInRedirect.learnedHost("http://trek.lan:3000", "http://auth.lan/", true, true, false))
|
||||
assertNull(SignInRedirect.learnedHost(null, "https://auth.example.com/", true, true, false))
|
||||
assertTrue(SignInRedirect.onServer(server, "https://TREK.example.com/dashboard"))
|
||||
assertFalse(SignInRedirect.onServer(server, "https://auth.example.com/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pushesLandInTheChannelOfTheirEvent() {
|
||||
assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message"))
|
||||
@@ -49,6 +71,16 @@ class NativeLogicTest {
|
||||
assertEquals("page", ShareLoader.slug("!!!"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLinkWhosePageCannotBeReadIsHandedOverAsTheLinkItself() {
|
||||
val item = ShareLoader.linkOnly("https://www.instagram.com/p/abc", null)
|
||||
assertEquals("text/uri-list", item.mimeType)
|
||||
assertEquals("https://www.instagram.com/p/abc", item.url)
|
||||
assertEquals("https://www.instagram.com/p/abc", String(item.data))
|
||||
assertEquals("www.instagram.com", item.title)
|
||||
assertEquals("Ramen place", ShareLoader.linkOnly("https://maps.app.goo.gl/x", "Ramen place").title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() {
|
||||
val today = LocalDate.of(2026, 11, 7)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,9 +2,15 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-action-sheet'
|
||||
project(':capacitor-action-sheet').projectDir = new File('../../node_modules/@capacitor/action-sheet/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-dialog'
|
||||
project(':capacitor-dialog').projectDir = new File('../../node_modules/@capacitor/dialog/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// The chunk group that holds the lucide icons the app loads at startup (vite.config.js).
|
||||
//
|
||||
// Every lucide icon is a module of its own, and the plugin icon lookup
|
||||
// (src/components/shared/PluginIcon.tsx) reaches all of them by name, from the navigation
|
||||
// every page renders. Left to itself the bundler puts each icon that some other chunk
|
||||
// also uses into a chunk of its own, so that neither side holds a copy: 187 files of a few
|
||||
// hundred bytes each, every one a request of its own on a first visit and an entry of its
|
||||
// own in the service worker's precache. In one group they are one file, whose hash changes
|
||||
// only when lucide-react does.
|
||||
//
|
||||
// `tags: ['$initial']` for the reason the other groups carry it: without it the group would
|
||||
// also take icons that today sit behind a lazy route and make them part of the first load.
|
||||
export const ICON_CHUNK = {
|
||||
name: 'vendor-icons',
|
||||
priority: 20,
|
||||
tags: ['$initial'],
|
||||
test: /[\\/]node_modules[\\/]lucide-react[\\/]/,
|
||||
};
|
||||
@@ -51,6 +51,7 @@
|
||||
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
|
||||
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; };
|
||||
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */; };
|
||||
896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
|
||||
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
|
||||
@@ -172,6 +173,7 @@
|
||||
10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; };
|
||||
114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; };
|
||||
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekClientIdentity.swift; sourceTree = "<group>"; };
|
||||
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -420,6 +422,7 @@
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
|
||||
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
@@ -823,6 +826,7 @@
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
|
||||
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -25,6 +25,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
TrekPush.shared.didFail(error)
|
||||
}
|
||||
|
||||
/// A silent push from the TREK server: a list of a trip changed, so the Apple
|
||||
/// Watch is brought up to date (TrekWatchRefresher). Nothing else arrives this way.
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
guard userInfo["trek"] as? String == "watch-sync" else { return completionHandler(.noData) }
|
||||
TrekWatchRefresher.refresh(completion: completionHandler)
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
completionHandler([.banner, .list, .sound])
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<string>TREK</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -120,6 +120,10 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -6,6 +6,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
// Launched by a quick action on the app icon: held until the UI asks for it.
|
||||
if let item = connectionOptions.shortcutItem { _ = TrekQuickActions.open(item) }
|
||||
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
window?.rootViewController = TrekBridgeViewController()
|
||||
@@ -45,6 +47,72 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
||||
}
|
||||
|
||||
// A quick action on the app icon while the app was running in the background.
|
||||
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||
completionHandler: @escaping (Bool) -> Void) {
|
||||
completionHandler(TrekQuickActions.open(shortcutItem))
|
||||
}
|
||||
}
|
||||
|
||||
/// The quick actions on the app icon (a long press on it): "New trip" and the
|
||||
/// trip coming up, set by the UI in its language (TrekShell.setQuickActions).
|
||||
/// Each carries the path it opens; a tap goes to the UI as that path, at once
|
||||
/// when it listens, or held for it to take when the tap launched the app.
|
||||
enum TrekQuickActions {
|
||||
private static let pathKey = "path"
|
||||
private static var pending: String?
|
||||
static var onOpen: ((String) -> Void)?
|
||||
|
||||
struct Item {
|
||||
let type: String
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let icon: String
|
||||
let path: String
|
||||
|
||||
init?(raw: JSObject) {
|
||||
guard let type = raw["type"] as? String, let title = raw["title"] as? String,
|
||||
let path = raw["path"] as? String, path.hasPrefix("/") else { return nil }
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.subtitle = raw["subtitle"] as? String
|
||||
self.icon = raw["icon"] as? String ?? ""
|
||||
self.path = path
|
||||
}
|
||||
}
|
||||
|
||||
static func set(_ items: [Item]) {
|
||||
UIApplication.shared.shortcutItems = items.prefix(4).map { item in
|
||||
UIApplicationShortcutItem(
|
||||
type: "com.liketrek.trek.\(item.type)",
|
||||
localizedTitle: item.title,
|
||||
localizedSubtitle: item.subtitle,
|
||||
icon: icon(item.icon),
|
||||
userInfo: [pathKey: item.path as NSString]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func icon(_ name: String) -> UIApplicationShortcutIcon {
|
||||
switch name {
|
||||
case "add": return UIApplicationShortcutIcon(type: .add)
|
||||
case "trip": return UIApplicationShortcutIcon(systemImageName: "suitcase")
|
||||
default: return UIApplicationShortcutIcon(type: .compose)
|
||||
}
|
||||
}
|
||||
|
||||
static func open(_ item: UIApplicationShortcutItem) -> Bool {
|
||||
guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false }
|
||||
if let onOpen { onOpen(path) } else { pending = path }
|
||||
return true
|
||||
}
|
||||
|
||||
/// The action the app was launched with, once.
|
||||
static func take() -> String? {
|
||||
defer { pending = nil }
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
/// The blur over the app while it sits in the app switcher, when the app lock
|
||||
@@ -110,6 +178,11 @@ enum TrekSharedFiles {
|
||||
"data": data.base64EncodedString(),
|
||||
]
|
||||
if let tripId = info["tripId"] as? Int { file["tripId"] = tripId }
|
||||
// A page or a link: where it came from, so the UI can keep it among the trip's links.
|
||||
if let url = info["url"] as? String, url.hasPrefix("https://") || url.hasPrefix("http://") {
|
||||
file["url"] = url
|
||||
if let title = info["title"] as? String { file["title"] = title }
|
||||
}
|
||||
pending = file
|
||||
onReceive?(file)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import WebKit
|
||||
class TrekBridgeViewController: CAPBridgeViewController {
|
||||
override func instanceDescriptor() -> InstanceDescriptor {
|
||||
let descriptor = super.instanceDescriptor()
|
||||
TrekServerStore.recheckAfterUpdate()
|
||||
if let server = TrekServerStore.url {
|
||||
// The trailing slash matters: Capacitor treats every URL that starts
|
||||
// with this string as the app's own, and without it that would take
|
||||
@@ -40,15 +41,289 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
// reachable" with a server that was there all along.
|
||||
if let webView, let inner = webView.navigationDelegate {
|
||||
let proxy = TrekNavigationProxy(inner: inner)
|
||||
proxy.onErrorPage = { [weak self] error in self?.errorPageChanged(error) }
|
||||
proxy.onPageShown = { [weak self] in self?.publishSafeArea(force: true) }
|
||||
navigationProxy = proxy
|
||||
webView.navigationDelegate = proxy
|
||||
}
|
||||
guardBridgeMessages()
|
||||
watchSignInPages()
|
||||
bridge?.registerPluginInstance(TrekShellPlugin())
|
||||
// WebKit's own swipe back slides away a screenshot of the whole page,
|
||||
// closes no sheet and takes every drag near the edge from the UI. The UI
|
||||
// draws its own (mobile/components/MEdgeBack.tsx, feature "edgeBack"):
|
||||
// the page follows the finger and an open sheet closes first.
|
||||
webView?.allowsBackForwardNavigationGestures = false
|
||||
// A long press on a link would lift a preview of the page it points to,
|
||||
// which no native list does; the press means "drag" or nothing here.
|
||||
webView?.allowsLinkPreview = false
|
||||
paintBehindWebView()
|
||||
}
|
||||
|
||||
// MARK: - What shows where the web view does not reach
|
||||
|
||||
/// The UI reports its theme through StatusBar.setStyle (native/nativeApp.ts),
|
||||
/// which lands here; the colour behind the web view follows it.
|
||||
override func setNeedsStatusBarAppearanceUpdate() {
|
||||
super.setNeedsStatusBarAppearanceUpdate()
|
||||
paintBehindWebView()
|
||||
}
|
||||
|
||||
/// Some iOS releases (27.0.x) lay the web view out below the status bar
|
||||
/// instead of under it, and the strip above the page then showed this
|
||||
/// view's black. It now carries the top colour of the screen background
|
||||
/// (mobile.css --m-scr), so the strip reads as part of the page on every
|
||||
/// release, and the rubber band at the ends shows the same colour.
|
||||
private func paintBehindWebView() {
|
||||
let dark: Bool
|
||||
switch preferredStatusBarStyle {
|
||||
case .lightContent: dark = true
|
||||
case .darkContent: dark = false
|
||||
default: dark = traitCollection.userInterfaceStyle == .dark
|
||||
}
|
||||
let color = dark
|
||||
? UIColor(red: 0x17 / 255, green: 0x17 / 255, blue: 0x1C / 255, alpha: 1)
|
||||
: UIColor(red: 0xFB / 255, green: 0xFA / 255, blue: 0xF7 / 255, alpha: 1)
|
||||
view.backgroundColor = color
|
||||
webView?.scrollView.backgroundColor = color
|
||||
webView?.underPageBackgroundColor = color
|
||||
}
|
||||
|
||||
// MARK: - The safe area the page keeps clear of
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
publishSafeArea()
|
||||
}
|
||||
|
||||
override func viewSafeAreaInsetsDidChange() {
|
||||
super.viewSafeAreaInsetsDidChange()
|
||||
publishSafeArea()
|
||||
}
|
||||
|
||||
private var publishedSafeArea: UIEdgeInsets?
|
||||
|
||||
/// WebKit's env(safe-area-inset-*) is the window's safe area only where the
|
||||
/// web view overlaps it. Where a release lays the web view out below the
|
||||
/// status bar it reports 0, yet a camera in a corner can still reach into
|
||||
/// the page. The part of the window's safe area (and of the status bar) that
|
||||
/// falls inside the web view goes to the page as --trek-native-safe-*, which
|
||||
/// mobile.css takes as a floor under env(), never in its place.
|
||||
func publishSafeArea(force: Bool = false) {
|
||||
guard let webView, let window = view.window else { return }
|
||||
let frame = webView.convert(webView.bounds, to: window)
|
||||
let safe = window.safeAreaInsets
|
||||
let statusBar = window.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
|
||||
let insets = UIEdgeInsets(
|
||||
top: max(0, max(safe.top, statusBar) - frame.minY),
|
||||
left: max(0, safe.left - frame.minX),
|
||||
bottom: max(0, safe.bottom - (window.bounds.maxY - frame.maxY)),
|
||||
right: max(0, safe.right - (window.bounds.maxX - frame.maxX))
|
||||
)
|
||||
if !force, insets == publishedSafeArea { return }
|
||||
publishedSafeArea = insets
|
||||
let px = { (v: CGFloat) in "'\(Int(v.rounded(.up)))px'" }
|
||||
let js = "(function(s){"
|
||||
+ "s.setProperty('--trek-native-safe-top',\(px(insets.top)));"
|
||||
+ "s.setProperty('--trek-native-safe-left',\(px(insets.left)));"
|
||||
+ "s.setProperty('--trek-native-safe-right',\(px(insets.right)));"
|
||||
+ "s.setProperty('--trek-native-safe-bottom',\(px(insets.bottom)));"
|
||||
+ "})(document.documentElement.style)"
|
||||
webView.evaluateJavaScript(js, completionHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: - A proxy's sign-in page
|
||||
|
||||
/// Over a proxy's sign-in page (Pangolin, authentik, ...) the server's UI is
|
||||
/// not there, so nothing else leads back to the address screen, and a passkey
|
||||
/// cannot work in a web view for a domain the app is not tied to. A bar says
|
||||
/// so and offers to change the server; the keyboard gets room on that page,
|
||||
/// which TREK's own script makes everywhere else (native/keepFieldInView.ts).
|
||||
private var signInBar: UIView?
|
||||
private var urlObservation: NSKeyValueObservation?
|
||||
private var insetObservation: NSKeyValueObservation?
|
||||
private var onSignInPage = false
|
||||
/// Whether the main frame shows an error page in place of the server's (a 4xx
|
||||
/// nobody else handled: a proxy's block page, a client certificate it refused).
|
||||
private var onErrorPage = false
|
||||
private var signInHint: UILabel?
|
||||
private var keyboardUp = false
|
||||
/// How far the keyboard reaches into the view while it is up.
|
||||
private var keyboardRoom: CGFloat = 0
|
||||
/// Whether the bottom inset is this controller's, so leaving the page puts it back.
|
||||
private var ownsInset = false
|
||||
private var bodyReset: DispatchWorkItem?
|
||||
|
||||
private func watchSignInPages() {
|
||||
urlObservation = webView?.observe(\.url, options: [.initial, .new]) { [weak self] webView, _ in
|
||||
let signIn = TrekServerStore.isSignInHost(webView.url)
|
||||
DispatchQueue.main.async { self?.signInPageChanged(signIn) }
|
||||
}
|
||||
// The keyboard plugin zeroes the scroll inset on every frame change it
|
||||
// makes, the last one a while after the keyboard is up. On a sign-in
|
||||
// page the inset wanted here is put back each time.
|
||||
insetObservation = webView?.scrollView.observe(\.contentInset, options: [.new]) { [weak self] _, _ in
|
||||
DispatchQueue.main.async { self?.applyInset() }
|
||||
}
|
||||
let center = NotificationCenter.default
|
||||
center.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
center.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
}
|
||||
|
||||
private func signInPageChanged(_ signIn: Bool) {
|
||||
onSignInPage = signIn
|
||||
if signIn && signInBar == nil { signInBar = makeSignInBar() }
|
||||
updateSignInBar()
|
||||
}
|
||||
|
||||
private func errorPageChanged(_ error: Bool) {
|
||||
onErrorPage = error
|
||||
if error && signInBar == nil { signInBar = makeSignInBar() }
|
||||
updateSignInBar()
|
||||
}
|
||||
|
||||
/// Out of the way while the keyboard is up: the form needs the room.
|
||||
private func updateSignInBar() {
|
||||
let labels = TrekServerStore.labels
|
||||
signInHint?.text = onSignInPage
|
||||
? labels["signInHint"]
|
||||
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
|
||||
: labels["errorPageHint"] ?? "This page is not from TREK: the server or a proxy in front of it refused the app. Check your proxy or certificate, or change the server."
|
||||
signInBar?.isHidden = !((onSignInPage || onErrorPage) && !keyboardUp)
|
||||
applyInset()
|
||||
}
|
||||
|
||||
/// On a sign-in page the page can scroll past what covers its end: the
|
||||
/// keyboard while it is up, the bar otherwise. TREK's own pages are left alone.
|
||||
private func applyInset() {
|
||||
guard let scrollView = webView?.scrollView else { return }
|
||||
let wanted: CGFloat
|
||||
if onSignInPage || onErrorPage {
|
||||
if keyboardUp {
|
||||
wanted = keyboardRoom
|
||||
} else if let bar = signInBar {
|
||||
view.layoutIfNeeded()
|
||||
wanted = max(0, view.bounds.height - bar.frame.minY)
|
||||
} else {
|
||||
wanted = 0
|
||||
}
|
||||
ownsInset = true
|
||||
} else if ownsInset {
|
||||
wanted = 0
|
||||
ownsInset = false
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if scrollView.contentInset.bottom != wanted { scrollView.contentInset.bottom = wanted }
|
||||
if scrollView.verticalScrollIndicatorInsets.bottom != wanted { scrollView.verticalScrollIndicatorInsets.bottom = wanted }
|
||||
}
|
||||
|
||||
/// The keyboard plugin also sizes the body to the area above the keyboard,
|
||||
/// after the keyboard's animation and a further 0.2 s. On a sign-in page the
|
||||
/// body gets its height back once that has run, so the button under the
|
||||
/// keyboard can be scrolled into view through the inset above.
|
||||
@objc private func keyboardWillShow(_ note: Notification) {
|
||||
keyboardUp = true
|
||||
if let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
|
||||
keyboardRoom = max(0, view.bounds.height - view.convert(frame, from: nil).minY)
|
||||
}
|
||||
bodyReset?.cancel()
|
||||
if onSignInPage {
|
||||
let duration = note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25
|
||||
let reset = DispatchWorkItem { [weak self] in
|
||||
guard let self, self.onSignInPage, self.keyboardUp, let webView = self.webView else { return }
|
||||
webView.evaluateJavaScript("document.body.style.height = ''", completionHandler: nil)
|
||||
self.applyInset()
|
||||
}
|
||||
bodyReset = reset
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.35, execute: reset)
|
||||
}
|
||||
updateSignInBar()
|
||||
}
|
||||
|
||||
@objc private func keyboardWillHide() {
|
||||
keyboardUp = false
|
||||
bodyReset?.cancel()
|
||||
updateSignInBar()
|
||||
}
|
||||
|
||||
private func makeSignInBar() -> UIView {
|
||||
let labels = TrekServerStore.labels
|
||||
// The look of the app's own first screens: a white card with a sand edge,
|
||||
// warm grey text and a dark button, over whatever page the proxy shows.
|
||||
let bar = UIView()
|
||||
bar.backgroundColor = UIColor(white: 1, alpha: 0.97)
|
||||
bar.layer.cornerRadius = 18
|
||||
bar.layer.cornerCurve = .continuous
|
||||
bar.layer.borderWidth = 1
|
||||
bar.layer.borderColor = UIColor(red: 0xEF / 255, green: 0xE4 / 255, blue: 0xD8 / 255, alpha: 1).cgColor
|
||||
bar.layer.shadowColor = UIColor(red: 0x1F / 255, green: 0x1A / 255, blue: 0x17 / 255, alpha: 1).cgColor
|
||||
bar.layer.shadowOpacity = 0.18
|
||||
bar.layer.shadowRadius = 18
|
||||
bar.layer.shadowOffset = CGSize(width: 0, height: 10)
|
||||
bar.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let hint = UILabel()
|
||||
hint.text = labels["signInHint"]
|
||||
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
|
||||
hint.textColor = UIColor(red: 0x6B / 255, green: 0x5F / 255, blue: 0x57 / 255, alpha: 1)
|
||||
hint.font = .preferredFont(forTextStyle: .footnote)
|
||||
hint.adjustsFontForContentSizeCategory = true
|
||||
hint.numberOfLines = 0
|
||||
signInHint = hint
|
||||
hint.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
var look = UIButton.Configuration.filled()
|
||||
look.baseBackgroundColor = UIColor(red: 0x11 / 255, green: 0x18 / 255, blue: 0x27 / 255, alpha: 1)
|
||||
look.baseForegroundColor = .white
|
||||
look.cornerStyle = .fixed
|
||||
look.background.cornerRadius = 12
|
||||
look.contentInsets = NSDirectionalEdgeInsets(top: 9, leading: 14, bottom: 9, trailing: 14)
|
||||
let change = UIButton(configuration: look)
|
||||
change.setTitle(labels["changeServer"] ?? "Change server", for: .normal)
|
||||
change.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
change.addTarget(self, action: #selector(changeServerFromSignIn), for: .touchUpInside)
|
||||
change.translatesAutoresizingMaskIntoConstraints = false
|
||||
change.setContentHuggingPriority(.required, for: .horizontal)
|
||||
change.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
|
||||
bar.addSubview(hint)
|
||||
bar.addSubview(change)
|
||||
view.addSubview(bar)
|
||||
NSLayoutConstraint.activate([
|
||||
bar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
|
||||
bar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
|
||||
bar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
|
||||
hint.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 14),
|
||||
hint.topAnchor.constraint(equalTo: bar.topAnchor, constant: 10),
|
||||
hint.bottomAnchor.constraint(equalTo: bar.bottomAnchor, constant: -10),
|
||||
change.leadingAnchor.constraint(equalTo: hint.trailingAnchor, constant: 8),
|
||||
change.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -12),
|
||||
change.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
|
||||
])
|
||||
return bar
|
||||
}
|
||||
|
||||
/// What resetServer does from the UI: forget the server, back to the address screen.
|
||||
@objc private func changeServerFromSignIn() {
|
||||
TrekServerStore.url = nil
|
||||
view.window?.rootViewController = TrekBridgeViewController()
|
||||
}
|
||||
|
||||
/// Kept alive here: the content controller holds it, but only for as long as it is registered.
|
||||
private var messageGuard: TrekBridgeMessageGuard?
|
||||
|
||||
/// Capacitor answers bridge messages from any page in the web view. With a
|
||||
/// proxy's sign-in page loading there, its messages are dropped before they
|
||||
/// reach the plugins. Always in place: a sign-in host can also be learned
|
||||
/// while the app runs (TrekSignInRedirect).
|
||||
private func guardBridgeMessages() {
|
||||
guard let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
|
||||
let controller = webView?.configuration.userContentController else { return }
|
||||
let guarded = TrekBridgeMessageGuard(inner: handler)
|
||||
controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name)
|
||||
controller.add(guarded, name: TrekBridgeMessageGuard.name)
|
||||
messageGuard = guarded
|
||||
}
|
||||
|
||||
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
|
||||
@@ -137,14 +412,120 @@ enum TrekKeyCommands {
|
||||
enum TrekServerStore {
|
||||
private static let key = "TrekServerURL"
|
||||
|
||||
private static let failureKey = "TrekLastFailure"
|
||||
|
||||
/// Why the server's page did not load ("NSURLErrorDomain -1003"): the
|
||||
/// address screen shows it small under its message, so a report carries
|
||||
/// the cause. Taken once.
|
||||
static func noteFailure(_ error: Error) {
|
||||
let ns = error as NSError
|
||||
UserDefaults.standard.set("\(ns.domain) \(ns.code)", forKey: failureKey)
|
||||
}
|
||||
|
||||
static func takeFailure() -> String? {
|
||||
let failure = UserDefaults.standard.string(forKey: failureKey)
|
||||
UserDefaults.standard.removeObject(forKey: failureKey)
|
||||
return failure
|
||||
}
|
||||
|
||||
private static let previousKey = "TrekPreviousServerURL"
|
||||
|
||||
private static let checkedKey = "TrekServerCheck"
|
||||
|
||||
private static let labelsKey = "TrekShellLabels"
|
||||
|
||||
/// The words of the bar over a proxy's sign-in page, in the user's language,
|
||||
/// as the address screen passed them (setServer's `labels`): the server's UI
|
||||
/// is not there to say them. Empty keeps the English fallback.
|
||||
static var labels: [String: String] {
|
||||
get { UserDefaults.standard.dictionary(forKey: labelsKey) as? [String: String] ?? [:] }
|
||||
set { UserDefaults.standard.set(newValue, forKey: labelsKey) }
|
||||
}
|
||||
|
||||
/// The server API this build needs (MIN_NATIVE_API in serverAddress.ts).
|
||||
/// A server remembered under a lower value is checked once more: the first
|
||||
/// launch after an update that raised it puts the address screen back up,
|
||||
/// filled in with that server (previousURL). Raise both together.
|
||||
private static let serverCheck = 3
|
||||
|
||||
/// Runs before the first bridge is built, so a server picked under an older
|
||||
/// minimum is not loaded unchecked.
|
||||
static func recheckAfterUpdate() {
|
||||
let defaults = UserDefaults.standard
|
||||
guard defaults.integer(forKey: checkedKey) < serverCheck else { return }
|
||||
defaults.set(serverCheck, forKey: checkedKey)
|
||||
guard let server = url else { return }
|
||||
url = nil
|
||||
defaults.set(server, forKey: previousKey)
|
||||
}
|
||||
|
||||
/// The server an update asked to check again, until a server is set.
|
||||
static var previousURL: String? {
|
||||
UserDefaults.standard.string(forKey: previousKey)
|
||||
}
|
||||
|
||||
private static let signInHostsKey = "TrekServerSignInHosts"
|
||||
|
||||
/// Setting the server drops the sign-in hosts of the one before it.
|
||||
static var url: String? {
|
||||
get { UserDefaults.standard.string(forKey: key) }
|
||||
set {
|
||||
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) }
|
||||
if let newValue {
|
||||
UserDefaults.standard.set(newValue, forKey: key)
|
||||
UserDefaults.standard.removeObject(forKey: previousKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
UserDefaults.standard.removeObject(forKey: signInHostsKey)
|
||||
try? FileManager.default.removeItem(at: offlineCopyURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// The hosts of a sign-in page in front of the server (a proxy such as
|
||||
/// Cloudflare Access or Pangolin), as the address screen found them. Only
|
||||
/// these load in the web view besides the server, so the proxy's session
|
||||
/// cookie is the web view's (TrekNavigationProxy), and none of them may
|
||||
/// talk to the app (TrekBridgeMessageGuard).
|
||||
static var signInHosts: Set<String> {
|
||||
get { Set((UserDefaults.standard.stringArray(forKey: signInHostsKey) ?? []).map { $0.lowercased() }) }
|
||||
set {
|
||||
if newValue.isEmpty {
|
||||
UserDefaults.standard.removeObject(forKey: signInHostsKey)
|
||||
} else {
|
||||
UserDefaults.standard.set(Array(newValue).sorted(), forKey: signInHostsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps one more sign-in host for the remembered server, as the web view met it (TrekSignInRedirect).
|
||||
static func addSignInHost(_ host: String) {
|
||||
var hosts = signInHosts
|
||||
guard hosts.insert(host.lowercased()).inserted else { return }
|
||||
signInHosts = hosts
|
||||
}
|
||||
|
||||
/// Whether `url` is one of the sign-in hosts, matched on host or host:port.
|
||||
static func isSignInHost(_ url: URL?) -> Bool {
|
||||
guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false }
|
||||
let hosts = signInHosts
|
||||
if hosts.contains(host) { return true }
|
||||
if let port = url.port { return hosts.contains("\(host):\(port)") }
|
||||
return false
|
||||
}
|
||||
|
||||
/// The one-time code of a TREK Cloud purchase (setServer's `welcome`). The
|
||||
/// server's first page opens its welcome page with it once, which signs the
|
||||
/// buyer in (TrekNavigationProxy). Kept in memory only, never on disk.
|
||||
static var pendingWelcome: String?
|
||||
|
||||
/// The welcome page for the pending code, handed out once.
|
||||
static func takeWelcomeURL() -> URL? {
|
||||
guard let code = pendingWelcome, let server = url else { return nil }
|
||||
pendingWelcome = nil
|
||||
let base = server.hasSuffix("/") ? server : server + "/"
|
||||
return URL(string: base + "api/managed/welcome#" + code)
|
||||
}
|
||||
|
||||
static var offlineCopyURL: URL {
|
||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
return support.appendingPathComponent("offline-start-page.html")
|
||||
@@ -366,12 +747,48 @@ 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
|
||||
/// Told on the main thread whether the main frame now shows an error page in place of the server's.
|
||||
var onErrorPage: ((Bool) -> Void)?
|
||||
/// Told when a page has finished loading: a new document starts without the safe area the controller handed the last one.
|
||||
var onPageShown: (() -> Void)?
|
||||
|
||||
init(inner: WKNavigationDelegate) {
|
||||
self.inner = inner
|
||||
@@ -391,13 +808,133 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
(inner as AnyObject).responds(to: aSelector) ? inner : nil
|
||||
}
|
||||
|
||||
/// The sign-in page of a proxy in front of the server loads here instead of
|
||||
/// in Safari, so its session is the web view's and every request carries it.
|
||||
/// Only the hosts the address screen saw (TrekServerStore.signInHosts), and
|
||||
/// only in the main frame; everything else is Capacitor's decision as before.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
if navigationAction.targetFrame?.isMainFrame == true, TrekServerStore.isSignInHost(navigationAction.request.url) {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
// WebKit asks again for every server redirect, as a navigation of type .other.
|
||||
if let host = TrekSignInRedirect.learnedHost(
|
||||
server: TrekServerStore.url,
|
||||
target: navigationAction.request.url,
|
||||
mainFrame: navigationAction.targetFrame?.isMainFrame == true,
|
||||
redirect: navigationAction.navigationType == .other,
|
||||
serverShown: serverShown
|
||||
) {
|
||||
TrekServerStore.addSignInHost(host)
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:")
|
||||
guard (inner as AnyObject).responds(to: selector) else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
inner.webView?(webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
|
||||
}
|
||||
|
||||
/// After the server's first page, a pending welcome code opens the welcome
|
||||
/// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
inner.webView?(webView, didFinish: navigation)
|
||||
onPageShown?()
|
||||
if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) {
|
||||
serverShown = true
|
||||
// The server's page is up: an earlier failure is no cause any more.
|
||||
_ = TrekServerStore.takeFailure()
|
||||
}
|
||||
if let welcome = TrekServerStore.takeWelcomeURL() {
|
||||
webView.load(URLRequest(url: welcome))
|
||||
}
|
||||
}
|
||||
|
||||
/// A proxy in front of the server asks for a client certificate (mutual TLS):
|
||||
/// the stored one goes to the host and port it was imported for and nowhere
|
||||
/// else (TrekClientIdentity). Every other challenge is Capacitor's as before.
|
||||
func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
if TrekClientIdentity.answer(challenge, completionHandler) {
|
||||
let space = challenge.protectionSpace
|
||||
TrekClientCertWatch.serverAsked(host: space.host, port: space.port) { [weak self, weak webView] in
|
||||
guard let self, let webView else { return }
|
||||
// Capacitor's own answer to a failed load: the address screen (errorPath).
|
||||
TrekServerStore.noteFailure(URLError(.clientCertificateRejected))
|
||||
self.inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRejected))
|
||||
}
|
||||
return
|
||||
}
|
||||
let selector = NSSelectorFromString("webView:didReceiveAuthenticationChallenge:completionHandler:")
|
||||
guard (inner as AnyObject).responds(to: selector) else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
inner.webView?(webView, didReceive: challenge, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
/// A proxy that asked the page for a client certificate, got none and
|
||||
/// answered with its own refusal (Cloudflare's 403, nginx's 400) sends the
|
||||
/// app to the address screen, which says so and offers to add one, rather
|
||||
/// than leaving the proxy's error page up. Any other 4xx page is not TREK's,
|
||||
/// which serves its UI for every path (a proxy's block page, a certificate it
|
||||
/// refused): it stays up with the bar and its way out to another server.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse,
|
||||
http.statusCode >= 400, http.statusCode < 500, let asked = TrekClientIdentity.lastRequest, !asked.served,
|
||||
let url = http.url, url.host?.lowercased() == asked.host, (url.port ?? 443) == asked.port {
|
||||
decisionHandler(.cancel)
|
||||
TrekServerStore.noteFailure(URLError(.clientCertificateRequired))
|
||||
inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRequired))
|
||||
return
|
||||
}
|
||||
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse {
|
||||
let error = http.statusCode >= 400 && http.statusCode < 500
|
||||
DispatchQueue.main.async { [weak self] in self?.onErrorPage?(error) }
|
||||
}
|
||||
let selector = NSSelectorFromString("webView:decidePolicyForNavigationResponse:decisionHandler:")
|
||||
guard (inner as AnyObject).responds(to: selector) else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
inner.webView?(webView, decidePolicyFor: navigationResponse, decisionHandler: decisionHandler)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
TrekServerStore.noteFailure(error)
|
||||
inner.webView?(webView, didFail: navigation, withError: error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
TrekServerStore.noteFailure(error)
|
||||
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in front of Capacitor's bridge message handler and drops what a
|
||||
/// proxy's sign-in page posts: that page loads in the web view (see
|
||||
/// TrekNavigationProxy), and Capacitor itself would hand its messages to the
|
||||
/// plugins whatever page they come from.
|
||||
final class TrekBridgeMessageGuard: NSObject, WKScriptMessageHandler {
|
||||
static let name = "bridge"
|
||||
private let inner: WKScriptMessageHandler
|
||||
|
||||
init(inner: WKScriptMessageHandler) {
|
||||
self.inner = inner
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
let origin = message.frameInfo.securityOrigin
|
||||
var components = URLComponents()
|
||||
components.scheme = origin.protocol
|
||||
components.host = origin.host
|
||||
if origin.port != 0 { components.port = origin.port }
|
||||
if TrekServerStore.isSignInHost(components.url) { return }
|
||||
inner.userContentController(userContentController, didReceive: message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// The client certificate a user's TREK server asks for (mutual TLS): a proxy
|
||||
/// such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through
|
||||
/// only devices that show one. The app holds at most one, for one server.
|
||||
///
|
||||
/// It comes in as a PKCS#12 file (.p12, .pfx) and its password. The password is
|
||||
/// never kept; the identity goes into the keychain, this device only, readable
|
||||
/// after the first unlock (so a background refresh can show it too). Only the
|
||||
/// host and port it was imported for ever see it (`identity(forHost:port:)`), so a
|
||||
/// sign-in page, a map tile provider or liketrek.com never learns of it.
|
||||
enum TrekClientIdentity {
|
||||
private static let label = "trek-client-identity"
|
||||
private static let hostKey = "TrekClientIdentityHost"
|
||||
private static let portKey = "TrekClientIdentityPort"
|
||||
|
||||
enum Failure: String, Error {
|
||||
case password, format, noKey = "no_key", expired, notYetValid = "not_yet_valid", store
|
||||
}
|
||||
|
||||
/// The last server that asked for a certificate while the app ran, and whether it got one.
|
||||
struct Request {
|
||||
let host: String
|
||||
let port: Int
|
||||
let served: Bool
|
||||
}
|
||||
|
||||
private static let lock = NSLock()
|
||||
private static var _lastRequest: Request?
|
||||
static var lastRequest: Request? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _lastRequest
|
||||
}
|
||||
|
||||
static func noteRequest(host: String, port: Int, served: Bool) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
_lastRequest = Request(host: host.lowercased(), port: port > 0 ? port : 443, served: served)
|
||||
}
|
||||
|
||||
// MARK: Reading a file
|
||||
|
||||
/// Opens a PKCS#12 file: the identity and the rest of its chain.
|
||||
static func read(_ data: Data, password: String, now: Date = Date()) -> Result<(SecIdentity, [SecCertificate]), Failure> {
|
||||
var items: CFArray?
|
||||
let status = SecPKCS12Import(data as CFData, [kSecImportExportPassphrase as String: password] as CFDictionary, &items)
|
||||
switch status {
|
||||
case errSecSuccess: break
|
||||
case errSecAuthFailed, errSecPkcs12VerifyFailure: return .failure(.password)
|
||||
default: return .failure(.format)
|
||||
}
|
||||
guard let first = (items as? [[String: Any]])?.first,
|
||||
let value = first[kSecImportItemIdentity as String] else { return .failure(.noKey) }
|
||||
let identity = value as! SecIdentity
|
||||
var leaf: SecCertificate?
|
||||
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return .failure(.noKey) }
|
||||
if let validity = validity(of: leaf) {
|
||||
if now > validity.notAfter { return .failure(.expired) }
|
||||
if now < validity.notBefore { return .failure(.notYetValid) }
|
||||
}
|
||||
let leafData = SecCertificateCopyData(leaf) as Data
|
||||
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate] ?? []).filter {
|
||||
SecCertificateCopyData($0) as Data != leafData
|
||||
}
|
||||
return .success((identity, chain))
|
||||
}
|
||||
|
||||
// MARK: Keeping it
|
||||
|
||||
/// Keeps the identity for this host and port, replacing any other.
|
||||
static func save(_ identity: SecIdentity, chain: [SecCertificate], host: String, port: Int) -> Bool {
|
||||
remove()
|
||||
let query: [String: Any] = [
|
||||
kSecValueRef as String: identity,
|
||||
kSecAttrLabel as String: label,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { return false }
|
||||
// The chain's intermediates go along so a proxy that wants them gets them.
|
||||
let encoded = chain.map { SecCertificateCopyData($0) as Data }
|
||||
UserDefaults.standard.set(encoded, forKey: chainKey)
|
||||
UserDefaults.standard.set(host.lowercased(), forKey: hostKey)
|
||||
UserDefaults.standard.set(port, forKey: portKey)
|
||||
return true
|
||||
}
|
||||
|
||||
private static let chainKey = "TrekClientIdentityChain"
|
||||
|
||||
static func remove() {
|
||||
TrekClientCertWatch.reset()
|
||||
for itemClass in [kSecClassIdentity, kSecClassCertificate, kSecClassKey] {
|
||||
SecItemDelete([kSecClass as String: itemClass, kSecAttrLabel as String: label] as CFDictionary)
|
||||
}
|
||||
UserDefaults.standard.removeObject(forKey: hostKey)
|
||||
UserDefaults.standard.removeObject(forKey: portKey)
|
||||
UserDefaults.standard.removeObject(forKey: chainKey)
|
||||
}
|
||||
|
||||
private static func storedIdentity() -> SecIdentity? {
|
||||
var item: CFTypeRef?
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassIdentity,
|
||||
kSecAttrLabel as String: label,
|
||||
kSecReturnRef as String: true,
|
||||
]
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item else { return nil }
|
||||
return (item as! SecIdentity)
|
||||
}
|
||||
|
||||
/// Whether a request to host and port (0 or less for https' default) goes to the server the identity is for.
|
||||
static func matches(host: String?, port: Int) -> Bool {
|
||||
guard let host, let stored = UserDefaults.standard.string(forKey: hostKey) else { return false }
|
||||
let storedPort = UserDefaults.standard.integer(forKey: portKey)
|
||||
return host.lowercased() == stored && (port > 0 ? port : 443) == (storedPort > 0 ? storedPort : 443)
|
||||
}
|
||||
|
||||
/// The credential for a server at host and port, or nil when the stored one is for another.
|
||||
static func credential(forHost host: String, port: Int) -> URLCredential? {
|
||||
guard matches(host: host, port: port), let identity = storedIdentity() else { return nil }
|
||||
let chain = (UserDefaults.standard.array(forKey: chainKey) as? [Data] ?? []).compactMap {
|
||||
SecCertificateCreateWithData(nil, $0 as CFData)
|
||||
}
|
||||
// Per session: a replaced certificate takes effect on the next connection.
|
||||
return URLCredential(identity: identity, certificates: chain.isEmpty ? nil : chain, persistence: .forSession)
|
||||
}
|
||||
|
||||
/// Answers a challenge if it asks for a client certificate; false leaves it to the caller.
|
||||
static func answer(_ challenge: URLAuthenticationChallenge, _ completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Bool {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate else { return false }
|
||||
let space = challenge.protectionSpace
|
||||
let credential = credential(forHost: space.host, port: space.port)
|
||||
noteRequest(host: space.host, port: space.port, served: credential != nil)
|
||||
if let credential {
|
||||
completion(.useCredential, credential)
|
||||
} else {
|
||||
// As a browser without a matching certificate: go on without one.
|
||||
completion(.performDefaultHandling, nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: What the settings show
|
||||
|
||||
static func info() -> [String: Any]? {
|
||||
guard let host = UserDefaults.standard.string(forKey: hostKey), let identity = storedIdentity() else { return nil }
|
||||
var leaf: SecCertificate?
|
||||
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return nil }
|
||||
let port = UserDefaults.standard.integer(forKey: portKey)
|
||||
var result: [String: Any] = [
|
||||
"host": host,
|
||||
"port": port > 0 ? port : 443,
|
||||
"subject": SecCertificateCopySubjectSummary(leaf) as String? ?? "",
|
||||
"issuer": issuerName(of: leaf) ?? "",
|
||||
]
|
||||
if let validity = validity(of: leaf) { result["notAfter"] = Int(validity.notAfter.timeIntervalSince1970 * 1000) }
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: Certificate fields
|
||||
|
||||
/// notBefore and notAfter from the DER: iOS has no public API for them.
|
||||
static func validity(of certificate: SecCertificate) -> (notBefore: Date, notAfter: Date)? {
|
||||
DER.validity(SecCertificateCopyData(certificate) as Data)
|
||||
}
|
||||
|
||||
private static func issuerName(of certificate: SecCertificate) -> String? {
|
||||
DER.issuerCommonName(SecCertificateCopyData(certificate) as Data)
|
||||
}
|
||||
|
||||
/// Just enough DER to read an X.509 certificate's validity and a name's CN.
|
||||
enum DER {
|
||||
private struct Node {
|
||||
let tag: UInt8
|
||||
let body: Range<Int>
|
||||
let end: Int
|
||||
}
|
||||
|
||||
private static func node(_ bytes: [UInt8], at offset: Int) -> Node? {
|
||||
guard offset + 2 <= bytes.count else { return nil }
|
||||
let tag = bytes[offset]
|
||||
var length = Int(bytes[offset + 1])
|
||||
var start = offset + 2
|
||||
if length & 0x80 != 0 {
|
||||
let count = length & 0x7f
|
||||
guard count > 0, count <= 4, start + count <= bytes.count else { return nil }
|
||||
length = bytes[start..<start + count].reduce(0) { $0 << 8 | Int($1) }
|
||||
start += count
|
||||
}
|
||||
guard start + length <= bytes.count else { return nil }
|
||||
return Node(tag: tag, body: start..<start + length, end: start + length)
|
||||
}
|
||||
|
||||
private static func children(_ bytes: [UInt8], in range: Range<Int>) -> [Node] {
|
||||
var result: [Node] = []
|
||||
var offset = range.lowerBound
|
||||
while offset < range.upperBound, let child = node(bytes, at: offset) {
|
||||
result.append(child)
|
||||
offset = child.end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// The fields of the TBSCertificate after the version: serial, signature algorithm, issuer, validity, ...
|
||||
private static func tbsFields(_ bytes: [UInt8]) -> [Node]? {
|
||||
guard let certificate = node(bytes, at: 0), let tbs = children(bytes, in: certificate.body).first else { return nil }
|
||||
var fields = children(bytes, in: tbs.body)
|
||||
if fields.first?.tag == 0xa0 { fields.removeFirst() }
|
||||
return fields.count > 3 ? fields : nil
|
||||
}
|
||||
|
||||
static func validity(_ data: Data) -> (notBefore: Date, notAfter: Date)? {
|
||||
let bytes = [UInt8](data)
|
||||
guard let fields = tbsFields(bytes) else { return nil }
|
||||
let times = children(bytes, in: fields[3].body)
|
||||
guard times.count == 2, let from = time(bytes, times[0]), let to = time(bytes, times[1]) else { return nil }
|
||||
return (from, to)
|
||||
}
|
||||
|
||||
private static func time(_ bytes: [UInt8], _ node: Node) -> Date? {
|
||||
guard let text = String(bytes: bytes[node.body], encoding: .ascii) else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||
formatter.dateFormat = node.tag == 0x17 ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
|
||||
return formatter.date(from: text)
|
||||
}
|
||||
|
||||
/// The CN of the issuer's Name (a SEQUENCE of SETs of type and value), or nil.
|
||||
static func issuerCommonName(_ data: Data) -> String? {
|
||||
let bytes = [UInt8](data)
|
||||
guard let fields = tbsFields(bytes) else { return nil }
|
||||
for set in children(bytes, in: fields[2].body) {
|
||||
for pair in children(bytes, in: set.body) {
|
||||
let parts = children(bytes, in: pair.body)
|
||||
guard parts.count == 2, Array(bytes[parts[0].body]) == [0x55, 0x04, 0x03] else { continue }
|
||||
return String(bytes: bytes[parts[1].body], encoding: .utf8)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notices when the user's server stops letting the app in for want of a
|
||||
/// client certificate (one removed, expired, or no longer trusted). The page can
|
||||
/// still be the stored offline copy while every request fails, so when the server
|
||||
/// asks for a certificate the app checks once natively whether TREK answers with
|
||||
/// what it holds. Only a check that fails at the certificate (the handshake broke
|
||||
/// off, or the proxy's own 4xx) calls `onRefused`; a server that merely asks and
|
||||
/// lets everyone in answers TREK's 200. At most once a minute.
|
||||
enum TrekClientCertWatch {
|
||||
private static let lock = NSLock()
|
||||
private static var lastCheck = Date.distantPast
|
||||
|
||||
/// A new or removed certificate is worth a check of its own, whenever the last one ran.
|
||||
static func reset() {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
lastCheck = .distantPast
|
||||
}
|
||||
|
||||
static func serverAsked(host: String, port: Int, onRefused: @escaping () -> Void) {
|
||||
guard let raw = TrekServerStore.url, let server = URL(string: raw), server.scheme == "https",
|
||||
server.host?.lowercased() == host.lowercased(), (server.port ?? 443) == (port > 0 ? port : 443),
|
||||
let features = URL(string: raw.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/health/features") else { return }
|
||||
lock.lock()
|
||||
let due = Date().timeIntervalSince(lastCheck) >= 60
|
||||
if due { lastCheck = Date() }
|
||||
lock.unlock()
|
||||
guard due else { return }
|
||||
var request = URLRequest(url: features, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
var task: URLSessionDataTask?
|
||||
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { _, response, _ in
|
||||
let asked = task.flatMap { TrekClientCertSessionDelegate.shared.ask(for: $0) } != nil
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if asked && (status == 0 || (400..<500).contains(status)) {
|
||||
DispatchQueue.main.async(execute: onRefused)
|
||||
}
|
||||
}
|
||||
task?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// A URLSession delegate for the app's own requests to the user's server: it
|
||||
/// shows the client certificate where the server asks for it and notes the ask.
|
||||
final class TrekClientCertSessionDelegate: NSObject, URLSessionTaskDelegate {
|
||||
static let shared = TrekClientCertSessionDelegate()
|
||||
|
||||
/// The app's session for the user's server (offline copy, watch, address check).
|
||||
static let session = URLSession(configuration: .default, delegate: shared, delegateQueue: nil)
|
||||
|
||||
/// Per task: whether the server asked, and whether it got a certificate (address check only).
|
||||
private let lock = NSLock()
|
||||
private var asks: [Int: Bool] = [:]
|
||||
|
||||
func ask(for task: URLSessionTask) -> Bool? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return asks.removeValue(forKey: task.taskIdentifier)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
let handled = TrekClientIdentity.answer(challenge) { disposition, credential in
|
||||
self.lock.lock()
|
||||
self.asks[task.taskIdentifier] = credential != nil
|
||||
self.lock.unlock()
|
||||
completionHandler(disposition, credential)
|
||||
}
|
||||
if !handled { completionHandler(.performDefaultHandling, nil) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The file picker and password prompt of an import, in the UI's words.
|
||||
final class TrekClientCertImporter: NSObject, UIDocumentPickerDelegate {
|
||||
private let presenter: UIViewController
|
||||
private let labels: [String: String]
|
||||
private let host: String
|
||||
private let port: Int
|
||||
private let done: ([String: Any]) -> Void
|
||||
private var keepAlive: TrekClientCertImporter?
|
||||
|
||||
init(presenter: UIViewController, labels: [String: String], host: String, port: Int, done: @escaping ([String: Any]) -> Void) {
|
||||
self.presenter = presenter
|
||||
self.labels = labels
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.done = done
|
||||
}
|
||||
|
||||
func start() {
|
||||
keepAlive = self
|
||||
let types = [UTType(filenameExtension: "p12"), UTType(filenameExtension: "pfx"), UTType("com.rsa.pkcs-12")].compactMap { $0 }
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types.isEmpty ? [.data] : types, asCopy: true)
|
||||
picker.delegate = self
|
||||
presenter.present(picker, animated: true)
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
finish(["result": "cancelled"])
|
||||
}
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let url = urls.first,
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
(attributes[.size] as? Int ?? 0) <= 256 * 1024,
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
finish(["result": "failed", "failure": TrekClientIdentity.Failure.format.rawValue])
|
||||
return
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
askPassword(for: data, wrong: false)
|
||||
}
|
||||
|
||||
private func askPassword(for data: Data, wrong: Bool) {
|
||||
let alert = UIAlertController(title: labels["title"], message: wrong ? labels["wrongPassword"] : labels["message"], preferredStyle: .alert)
|
||||
alert.addTextField { field in
|
||||
field.isSecureTextEntry = true
|
||||
field.placeholder = self.labels["placeholder"]
|
||||
field.textContentType = .password
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: labels["cancel"] ?? "Cancel", style: .cancel) { _ in
|
||||
self.finish(["result": "cancelled"])
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: labels["ok"] ?? "OK", style: .default) { _ in
|
||||
self.open(data, password: alert.textFields?.first?.text ?? "")
|
||||
})
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func open(_ data: Data, password: String) {
|
||||
switch TrekClientIdentity.read(data, password: password) {
|
||||
case .failure(.password):
|
||||
askPassword(for: data, wrong: true)
|
||||
case .failure(let failure):
|
||||
finish(["result": "failed", "failure": failure.rawValue])
|
||||
case .success(let (identity, chain)):
|
||||
guard TrekClientIdentity.save(identity, chain: chain, host: host, port: port) else {
|
||||
finish(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
|
||||
return
|
||||
}
|
||||
var result: [String: Any] = ["result": "imported"]
|
||||
if let info = TrekClientIdentity.info() { result["certificate"] = info }
|
||||
finish(result)
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: [String: Any]) {
|
||||
done(result)
|
||||
keepAlive = nil
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import ActivityKit
|
||||
#endif
|
||||
import LocalAuthentication
|
||||
import PassKit
|
||||
import QuickLook
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
@@ -45,10 +46,18 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeHeld", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setQuickActions", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "takeQuickAction", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "requestServer", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "previewFile", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// What this binary offers the server's UI, raised with every native feature
|
||||
@@ -60,7 +69,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
|
||||
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
|
||||
static var features: [String] {
|
||||
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands"]
|
||||
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate", "filePreview"]
|
||||
if canAddWalletPasses { names.append("wallet") }
|
||||
if TrekWatchLink.isAvailable { names.append("watch") }
|
||||
return names
|
||||
@@ -85,6 +94,31 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
TrekKeyCommands.onPress = { [weak self] id in
|
||||
self?.notifyListeners("keyCommand", data: ["id": id])
|
||||
}
|
||||
TrekQuickActions.onOpen = { [weak self] path in
|
||||
DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path]) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quick actions on the app icon
|
||||
|
||||
/// The app icon's quick actions, in the UI's language (TrekQuickActions).
|
||||
@objc func setQuickActions(_ call: CAPPluginCall) {
|
||||
let items = (call.getArray("items", JSObject.self) ?? []).compactMap { TrekQuickActions.Item(raw: $0) }
|
||||
DispatchQueue.main.async {
|
||||
TrekQuickActions.set(items)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/// The quick action the app was launched with, before the UI was there to hear of it.
|
||||
@objc func takeQuickAction(_ call: CAPPluginCall) {
|
||||
DispatchQueue.main.async {
|
||||
if let path = TrekQuickActions.take() {
|
||||
call.resolve(["path": path])
|
||||
} else {
|
||||
call.resolve(["path": NSNull()])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard
|
||||
@@ -315,7 +349,11 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
private var authSession: ASWebAuthenticationSession?
|
||||
|
||||
@objc func getServer(_ call: CAPPluginCall) {
|
||||
call.resolve(["url": TrekServerStore.url ?? NSNull()])
|
||||
call.resolve([
|
||||
"url": TrekServerStore.url ?? NSNull(),
|
||||
"previousUrl": TrekServerStore.previousURL ?? NSNull(),
|
||||
"failure": TrekServerStore.takeFailure() ?? NSNull(),
|
||||
])
|
||||
}
|
||||
|
||||
@objc func setServer(_ call: CAPPluginCall) {
|
||||
@@ -324,6 +362,14 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
return
|
||||
}
|
||||
TrekServerStore.url = raw
|
||||
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
|
||||
if !labels.isEmpty { TrekServerStore.labels = labels }
|
||||
// After the URL: setting it clears the hosts of the server before.
|
||||
let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil }
|
||||
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
|
||||
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
|
||||
let welcome = call.getString("welcome") ?? ""
|
||||
TrekServerStore.pendingWelcome = welcome.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil ? welcome : nil
|
||||
call.resolve()
|
||||
restartBridge()
|
||||
}
|
||||
@@ -396,7 +442,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
}
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||
URLSession.shared.dataTask(with: request) { data, response, _ in
|
||||
// The app's session, so a server behind mutual TLS gets the client certificate.
|
||||
TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, _ in
|
||||
guard let data, let http = response as? HTTPURLResponse, http.statusCode == 200,
|
||||
http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/html") == true,
|
||||
TrekServerStore.url == raw else {
|
||||
@@ -435,8 +482,123 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Client certificates (mutual TLS)
|
||||
|
||||
extension TrekShellPlugin {
|
||||
/// The address screen's check of a server, made natively so it notices
|
||||
/// whether the server asks for a client certificate and can show the stored
|
||||
/// one. A failed connection resolves with status 0 instead of rejecting.
|
||||
@objc func requestServer(_ call: CAPPluginCall) {
|
||||
guard let raw = call.getString("url"), let url = URL(string: raw),
|
||||
["https", "http"].contains(url.scheme?.lowercased() ?? ""), url.host != nil else {
|
||||
call.reject("A http(s) URL is required")
|
||||
return
|
||||
}
|
||||
let timeout = min(max(Double(call.getInt("timeoutMs") ?? 10000), 1000), 30000) / 1000
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
|
||||
request.setValue("application/json, text/html;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept")
|
||||
let delegate = TrekClientCertSessionDelegate.shared
|
||||
var task: URLSessionDataTask?
|
||||
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, error in
|
||||
let asked = task.flatMap { delegate.ask(for: $0) }
|
||||
var result: [String: Any] = [
|
||||
"certRequested": asked != nil,
|
||||
"certSent": asked == true,
|
||||
"url": response?.url?.absoluteString ?? raw,
|
||||
]
|
||||
if asked != nil, let host = url.host?.lowercased() { result["certHost"] = "\(host):\(url.port ?? 443)" }
|
||||
if let http = response as? HTTPURLResponse {
|
||||
result["status"] = http.statusCode
|
||||
let body = (data ?? Data()).prefix(512 * 1024)
|
||||
result["data"] = String(data: body, encoding: .utf8) ?? ""
|
||||
var headers: [String: String] = [:]
|
||||
for (name, value) in http.allHeaderFields {
|
||||
if let name = name as? String, let value = value as? String { headers[name] = value }
|
||||
}
|
||||
result["headers"] = headers
|
||||
} else {
|
||||
result["status"] = 0
|
||||
result["data"] = ""
|
||||
if let error { result["error"] = (error as NSError).domain + ":" + String((error as NSError).code) }
|
||||
}
|
||||
call.resolve(result)
|
||||
}
|
||||
task?.resume()
|
||||
}
|
||||
|
||||
/// The stored client certificate's details, and the last server that asked for one.
|
||||
@objc func clientCertificate(_ call: CAPPluginCall) {
|
||||
var result: [String: Any] = [:]
|
||||
if let info = TrekClientIdentity.info() { result["certificate"] = info } else { result["certificate"] = NSNull() }
|
||||
if let asked = TrekClientIdentity.lastRequest {
|
||||
result["request"] = ["host": asked.host, "port": asked.port, "served": asked.served]
|
||||
}
|
||||
call.resolve(result)
|
||||
}
|
||||
|
||||
@objc func removeClientCertificate(_ call: CAPPluginCall) {
|
||||
TrekClientIdentity.remove()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/// Picks a .p12/.pfx file, asks for its password until it opens or the user
|
||||
/// gives up, and keeps it for the server at host and port.
|
||||
@objc func importClientCertificate(_ call: CAPPluginCall) {
|
||||
guard let host = call.getString("host"), host.range(of: "^[A-Za-z0-9.-]+$", options: .regularExpression) != nil else {
|
||||
call.reject("A host is required")
|
||||
return
|
||||
}
|
||||
let port = call.getInt("port") ?? 443
|
||||
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let presenter = self?.bridge?.viewController else {
|
||||
call.resolve(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
|
||||
return
|
||||
}
|
||||
TrekClientCertImporter(presenter: presenter, labels: labels, host: host, port: port) { result in
|
||||
call.resolve(result)
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
|
||||
/// When-In-Use permission asked the first time.
|
||||
// MARK: - Quick Look
|
||||
|
||||
extension TrekShellPlugin {
|
||||
/// Kept while Quick Look shows a file: the controller holds its data source weakly.
|
||||
/// One preview at a time, so a static: an extension cannot store per instance.
|
||||
private static var previewSource: TrekFilePreviewSource?
|
||||
|
||||
/// Shows a file the UI wrote to the cache in Quick Look: a PDF to page through,
|
||||
/// an image, a document, with the system's own share button. "unsupported" for
|
||||
/// a file Quick Look cannot show, which the UI then hands to the share sheet.
|
||||
@objc func previewFile(_ call: CAPPluginCall) {
|
||||
guard let path = call.getString("path"), let url = URL(string: path), url.isFileURL,
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
call.reject("a file in the app's cache is required", "INVALID")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let presenter = self.bridge?.viewController else {
|
||||
call.reject("nothing to show the preview from", "UNAVAILABLE")
|
||||
return
|
||||
}
|
||||
guard QLPreviewController.canPreview(url as QLPreviewItem) else {
|
||||
call.resolve(["result": "unsupported"])
|
||||
return
|
||||
}
|
||||
let source = TrekFilePreviewSource(url: url, title: call.getString("title"))
|
||||
TrekShellPlugin.previewSource = source
|
||||
let preview = QLPreviewController()
|
||||
preview.dataSource = source
|
||||
presenter.present(preview, animated: true)
|
||||
call.resolve(["result": "shown"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Apple Wallet
|
||||
|
||||
extension TrekShellPlugin {
|
||||
@@ -519,6 +681,17 @@ extension TrekShellPlugin {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
// One subscription per Apple ID: another plan would change it, not add one.
|
||||
// A held one that was delivered stops the purchase here. One that was paid
|
||||
// but never set up is handed back as bought, so its instance is set up now.
|
||||
if let held = await TrekStore.currentEntitlement(prefix: "cloud.") {
|
||||
if await TrekStore.isUnfinished(held.id) {
|
||||
call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId])
|
||||
} else {
|
||||
call.resolve(["kind": "subscribed"])
|
||||
}
|
||||
return
|
||||
}
|
||||
do {
|
||||
guard let product = try await Product.products(for: [id]).first else {
|
||||
call.resolve(["kind": "failed"])
|
||||
@@ -575,6 +748,21 @@ extension TrekShellPlugin {
|
||||
call.resolve(["jws": found.jws, "transactionId": String(found.id)])
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature storeKit: whether this Apple ID holds a delivered subscription under
|
||||
/// `prefix`. Read from the entitlements on the device, so no password is asked;
|
||||
/// while one runs, the shell offers no purchase.
|
||||
@objc func storeHeld(_ call: CAPPluginCall) {
|
||||
let prefix = call.getString("prefix") ?? "cloud."
|
||||
Task {
|
||||
guard let held = await TrekStore.currentEntitlement(prefix: prefix) else {
|
||||
call.resolve(["held": false])
|
||||
return
|
||||
}
|
||||
let unfinished = await TrekStore.isUnfinished(held.id)
|
||||
call.resolve(["held": !unfinished])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TrekStore {
|
||||
@@ -605,17 +793,25 @@ enum TrekStore {
|
||||
}
|
||||
}
|
||||
|
||||
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64)? {
|
||||
var newest: (date: Date, jws: String, id: UInt64)?
|
||||
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64, productId: String)? {
|
||||
var newest: (date: Date, jws: String, id: UInt64, productId: String)?
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result,
|
||||
transaction.productID.hasPrefix(prefix),
|
||||
transaction.revocationDate == nil else { continue }
|
||||
if newest == nil || transaction.purchaseDate > newest!.date {
|
||||
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id)
|
||||
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id, transaction.productID)
|
||||
}
|
||||
}
|
||||
return newest.map { ($0.jws, $0.id) }
|
||||
return newest.map { (jws: $0.jws, id: $0.id, productId: $0.productId) }
|
||||
}
|
||||
|
||||
/// Paid but not delivered yet: the shell finishes a transaction once its instance exists.
|
||||
static func isUnfinished(_ id: UInt64) async -> Bool {
|
||||
for await result in Transaction.unfinished {
|
||||
if case .verified(let transaction) = result, transaction.id == id { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,3 +1035,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import CryptoKit
|
||||
import Foundation
|
||||
import UIKit
|
||||
import WatchConnectivity
|
||||
import WebKit
|
||||
|
||||
/// The phone's end of WatchConnectivity (feature "watch"). The UI hands over the
|
||||
/// watch's snapshot (client/src/native/watch.ts through TrekShell.setWatchSnapshot);
|
||||
@@ -93,6 +94,11 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
return result
|
||||
}
|
||||
|
||||
/// The snapshot the watch has now, as last published.
|
||||
func storedSnapshot() -> Data? {
|
||||
queue.sync { UserDefaults.standard.data(forKey: snapshotKey) }
|
||||
}
|
||||
|
||||
// MARK: Ticks from the watch
|
||||
|
||||
/// The ticks queued so far, oldest first, and the queue emptied.
|
||||
@@ -110,6 +116,25 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
return list
|
||||
}
|
||||
|
||||
/// While the app is in front, its UI writes the tick, through its offline queue
|
||||
/// as a tap would. In the background the WebView is asleep, so the tick is
|
||||
/// written to the server from here at once (TrekWatchWriter), and the trip's
|
||||
/// other devices hear of it over their WebSocket; only a write that fails is
|
||||
/// queued for the UI, which then tries again once it runs.
|
||||
private func receive(_ action: [String: Any], then done: @escaping () -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
guard UIApplication.shared.applicationState != .active else {
|
||||
self.enqueue(action)
|
||||
done()
|
||||
return
|
||||
}
|
||||
TrekWatchWriter.write(action) { written in
|
||||
if !written { self.enqueue(action) }
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ action: [String: Any]) {
|
||||
queue.async {
|
||||
// A tick sent twice (the watch resends after a restart) replaces the earlier copy.
|
||||
@@ -145,11 +170,16 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
if let action = userInfo["action"] as? [String: Any] { enqueue(action) }
|
||||
if let action = userInfo["action"] as? [String: Any] { receive(action) {} }
|
||||
}
|
||||
|
||||
/// The watch asks for the latest snapshot when it opens while the phone is in reach.
|
||||
/// The watch asks for the latest snapshot when it opens while the phone is in
|
||||
/// reach, and sends its ticks this way while it is (WatchLink.send).
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
if let action = message["action"] as? [String: Any] {
|
||||
receive(action) { replyHandler(["queued": true]) }
|
||||
return
|
||||
}
|
||||
queue.async {
|
||||
if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) {
|
||||
replyHandler(["snapshot": data])
|
||||
@@ -159,3 +189,211 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a tick from the watch straight to the TREK server, for when the app is
|
||||
/// in the background and its UI cannot. The same request the UI makes for a tap
|
||||
/// (PUT /api/trips/:trip/packing|todo/:item with the box's new state), with the
|
||||
/// session the WebView holds (its cookies) and the tick's id as the idempotency
|
||||
/// key, so a tick that also reaches the server another way is taken once.
|
||||
enum TrekWatchWriter {
|
||||
private final class BackgroundTask {
|
||||
var id = UIBackgroundTaskIdentifier.invalid
|
||||
|
||||
func end() {
|
||||
guard id != .invalid else { return }
|
||||
UIApplication.shared.endBackgroundTask(id)
|
||||
id = .invalid
|
||||
}
|
||||
}
|
||||
|
||||
/// Called on the main queue, as is `completion`.
|
||||
static func write(_ action: [String: Any], completion: @escaping (Bool) -> Void) {
|
||||
guard let request = Self.request(for: action) else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
let task = BackgroundTask()
|
||||
task.id = UIApplication.shared.beginBackgroundTask(withName: "trek.watch.tick") { task.end() }
|
||||
let finish: (Bool) -> Void = { written in
|
||||
DispatchQueue.main.async {
|
||||
completion(written)
|
||||
task.end()
|
||||
}
|
||||
}
|
||||
perform(request) { _, ok in finish(ok) }
|
||||
}
|
||||
|
||||
/// Sends a request with the WebView's session (its cookies for the server).
|
||||
/// Called on the main queue; `completion` runs on a background queue.
|
||||
static func perform(_ request: URLRequest, completion: @escaping (Data?, Bool) -> Void) {
|
||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||
guard let host = request.url?.host else { return completion(nil, false) }
|
||||
let session = cookies.filter { cookie in
|
||||
let domain = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain
|
||||
return host == domain || host.hasSuffix("." + domain)
|
||||
}
|
||||
guard !session.isEmpty else { return completion(nil, false) }
|
||||
var signed = request
|
||||
for (field, value) in HTTPCookie.requestHeaderFields(with: session) {
|
||||
signed.setValue(value, forHTTPHeaderField: field)
|
||||
}
|
||||
TrekClientCertSessionDelegate.session.dataTask(with: signed) { data, response, error in
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
completion(data, error == nil && (200..<300).contains(status))
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's address for an API path, or nil without a server.
|
||||
static func url(_ path: [String]) -> URL? {
|
||||
guard let server = TrekServerStore.url, let base = URL(string: server),
|
||||
base.scheme == "https" || base.scheme == "http" else { return nil }
|
||||
return path.reduce(base.appendingPathComponent("api")) { $0.appendingPathComponent($1) }
|
||||
}
|
||||
|
||||
/// The write for a tick as the watch sends it, or nil for anything else.
|
||||
static func request(for action: [String: Any]) -> URLRequest? {
|
||||
guard let server = TrekServerStore.url, let base = URL(string: server),
|
||||
base.scheme == "https" || base.scheme == "http",
|
||||
let id = action["id"] as? String, !id.isEmpty, id.count <= 64,
|
||||
let tripId = action["tripId"] as? Int, let itemId = action["itemId"] as? Int,
|
||||
let list = action["list"] as? String, list == "packing" || list == "todo",
|
||||
let done = action["done"] as? Bool,
|
||||
let body = try? JSONSerialization.data(withJSONObject: ["checked": done]) else { return nil }
|
||||
let url = base.appendingPathComponent("api").appendingPathComponent("trips")
|
||||
.appendingPathComponent(String(tripId)).appendingPathComponent(list).appendingPathComponent(String(itemId))
|
||||
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||
request.httpMethod = "PUT"
|
||||
request.httpShouldHandleCookies = false
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("watch-\(id)", forHTTPHeaderField: "X-Idempotency-Key")
|
||||
request.httpBody = body
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings the watch's packing list and to-dos up to date while the app is in the
|
||||
/// background, woken by the server's silent push (WatchSyncPushService in TREK):
|
||||
/// reads both lists of the trip the watch shows from the server and puts them
|
||||
/// into the snapshot the watch has, built the way the UI builds them
|
||||
/// (watchPacking and watchTodos in client/src/native/watch.ts). Everything else
|
||||
/// in the snapshot stays as the UI last made it. In front, the UI does this
|
||||
/// itself as soon as the lists change, so nothing is done here then.
|
||||
enum TrekWatchRefresher {
|
||||
private static let maxName = 80
|
||||
private static let maxGroup = 40
|
||||
/// Below what WatchConnectivity carries in one application context.
|
||||
private static let maxBytes = 55_000
|
||||
|
||||
static func refresh(completion: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
guard TrekWatchLink.isAvailable, UIApplication.shared.applicationState != .active,
|
||||
let stored = TrekWatchLink.shared.storedSnapshot(),
|
||||
var snapshot = (try? JSONSerialization.jsonObject(with: stored)) as? [String: Any],
|
||||
let trip = snapshot["trip"] as? [String: Any], let tripId = trip["id"] as? Int,
|
||||
let packingURL = TrekWatchWriter.url(["trips", String(tripId), "packing"]),
|
||||
let todoURL = TrekWatchWriter.url(["trips", String(tripId), "todo"]) else {
|
||||
return completion(.noData)
|
||||
}
|
||||
let group = DispatchGroup()
|
||||
var packing: [[String: Any]]?
|
||||
var todos: [[String: Any]]?
|
||||
group.enter()
|
||||
items(at: packingURL) { list in
|
||||
packing = list
|
||||
group.leave()
|
||||
}
|
||||
group.enter()
|
||||
items(at: todoURL) { list in
|
||||
todos = list
|
||||
group.leave()
|
||||
}
|
||||
group.notify(queue: .main) {
|
||||
guard let packing, let todos else { return completion(.failed) }
|
||||
snapshot["packing"] = packingList(packing)
|
||||
snapshot["todos"] = todoList(todos)
|
||||
snapshot["generatedAt"] = ISO8601DateFormatter().string(from: Date())
|
||||
guard let data = fitted(snapshot) else { return completion(.failed) }
|
||||
TrekWatchLink.shared.publish(data)
|
||||
completion(.newData)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `items` of a list endpoint, on the main queue; nil when it could not be read.
|
||||
private static func items(at url: URL, then completion: @escaping ([[String: Any]]?) -> Void) {
|
||||
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||
request.httpShouldHandleCookies = false
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
TrekWatchWriter.perform(request) { data, ok in
|
||||
var list: [[String: Any]]?
|
||||
if ok, let data, let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
|
||||
list = body["items"] as? [[String: Any]]
|
||||
}
|
||||
DispatchQueue.main.async { completion(list) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func done(_ item: [String: Any]) -> Bool {
|
||||
(item["checked"] as? Bool) ?? ((item["checked"] as? Int).map { $0 != 0 } ?? false)
|
||||
}
|
||||
|
||||
private static func trimmed(_ value: Any?, _ max: Int) -> String? {
|
||||
guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil }
|
||||
return String(text.prefix(max))
|
||||
}
|
||||
|
||||
private static func entry(_ item: [String: Any]) -> [String: Any]? {
|
||||
guard let id = item["id"] as? Int, let name = item["name"] as? String else { return nil }
|
||||
var out: [String: Any] = ["id": id, "name": String(name.prefix(maxName)), "done": done(item)]
|
||||
if let group = trimmed(item["category"], maxGroup) { out["group"] = group }
|
||||
return out
|
||||
}
|
||||
|
||||
/// Unpacked first, then packed, each in the list's own order.
|
||||
static func packingList(_ items: [[String: Any]]) -> Any {
|
||||
guard !items.isEmpty else { return NSNull() }
|
||||
let sorted = items.sorted {
|
||||
let (a, b) = (done($0) ? 1 : 0, done($1) ? 1 : 0)
|
||||
return a != b ? a < b : (($0["sort_order"] as? Int) ?? 0) < (($1["sort_order"] as? Int) ?? 0)
|
||||
}
|
||||
let packed = items.filter { done($0) }.count
|
||||
return ["items": sorted.compactMap(entry), "packed": packed, "total": items.count]
|
||||
}
|
||||
|
||||
/// Open ones first by due date (undated last) and priority, then the done ones.
|
||||
static func todoList(_ items: [[String: Any]]) -> Any {
|
||||
guard !items.isEmpty else { return NSNull() }
|
||||
let due = { (item: [String: Any]) -> String? in trimmed(item["due_date"], 10) }
|
||||
let sorted = items.sorted { a, b in
|
||||
if done(a) != done(b) { return !done(a) }
|
||||
if (due(a) == nil) != (due(b) == nil) { return due(a) != nil }
|
||||
if let da = due(a), let db = due(b), da != db { return da < db }
|
||||
let (pa, pb) = ((a["priority"] as? Int) ?? 0, (b["priority"] as? Int) ?? 0)
|
||||
if pa != pb { return pa > pb }
|
||||
return ((a["sort_order"] as? Int) ?? 0) < ((b["sort_order"] as? Int) ?? 0)
|
||||
}
|
||||
let rows = sorted.compactMap { item -> [String: Any]? in
|
||||
guard var row = entry(item) else { return nil }
|
||||
if let date = due(item) { row["due"] = date }
|
||||
if let priority = item["priority"] as? Int, priority > 0 { row["priority"] = min(3, priority) }
|
||||
return row
|
||||
}
|
||||
return ["items": rows, "open": items.filter { !done($0) }.count, "total": items.count]
|
||||
}
|
||||
|
||||
/// The snapshot as JSON within the size the watch takes: done items go first, as on the UI's side.
|
||||
private static func fitted(_ snapshot: [String: Any]) -> Data? {
|
||||
var object = snapshot
|
||||
// A list that is now empty leaves the snapshot, as the UI leaves it out.
|
||||
for key in ["packing", "todos"] where object[key] is NSNull { object.removeValue(forKey: key) }
|
||||
guard var data = try? JSONSerialization.data(withJSONObject: object) else { return nil }
|
||||
if data.count > maxBytes {
|
||||
for key in ["packing", "todos"] {
|
||||
guard var list = object[key] as? [String: Any], let items = list["items"] as? [[String: Any]] else { continue }
|
||||
list["items"] = items.filter { ($0["done"] as? Bool) != true }
|
||||
object[key] = list
|
||||
}
|
||||
data = (try? JSONSerialization.data(withJSONObject: object)) ?? data
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"),
|
||||
.package(name: "CapacitorActionSheet", path: "../../../../node_modules/@capacitor/action-sheet"),
|
||||
.package(name: "CapacitorApp", path: "../../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorDialog", path: "../../../../node_modules/@capacitor/dialog"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorHaptics", path: "../../../../node_modules/@capacitor/haptics"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../../node_modules/@capacitor/keyboard"),
|
||||
@@ -26,7 +28,9 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorActionSheet", package: "CapacitorActionSheet"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorDialog", package: "CapacitorDialog"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
|
||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||
|
||||
@@ -54,6 +54,10 @@ enum ShareHandoff {
|
||||
static func store(_ item: ShareItem, tripId: Int?) -> Bool {
|
||||
var meta: [String: Any] = ["name": item.name, "mimeType": item.mimeType]
|
||||
if let tripId { meta["tripId"] = tripId }
|
||||
if let url = item.url {
|
||||
meta["url"] = url
|
||||
meta["title"] = item.title
|
||||
}
|
||||
guard let metaData = try? JSONSerialization.data(withJSONObject: meta) else { return false }
|
||||
return write(dataAccount, item.data) && write(metaAccount, metaData)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ struct ShareItem {
|
||||
var title: String
|
||||
/// A host, for a page.
|
||||
var source: String?
|
||||
/// The web address it came from, when it is a page or a link: the app offers to
|
||||
/// keep it among the trip's links as well as to read a booking out of it.
|
||||
var url: String? = nil
|
||||
|
||||
var sizeLabel: String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
|
||||
@@ -88,6 +91,10 @@ enum ShareLoader {
|
||||
let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw ShareLoadError.unsupported }
|
||||
// A text that is just a link (what many apps share) is that link.
|
||||
if !trimmed.contains(where: \.isWhitespace), let url = URL(string: trimmed), webAddress(url) != nil {
|
||||
return try await page(at: url)
|
||||
}
|
||||
let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
|
||||
return try checked(ShareItem(kind: .text, name: "shared-text.txt", mimeType: "text/plain",
|
||||
data: Data(trimmed.utf8), title: String(firstLine.prefix(80)), source: nil))
|
||||
@@ -133,21 +140,39 @@ enum ShareLoader {
|
||||
let url = (results["url"] as? String).flatMap(URL.init(string:))
|
||||
let title = (results["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? url?.host ?? "Page"
|
||||
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
||||
data: Data(html.utf8), title: title, source: url?.host))
|
||||
data: Data(html.utf8), title: title, source: url?.host,
|
||||
url: url.flatMap(webAddress)))
|
||||
}
|
||||
|
||||
/// A link shared from an app other than Safari: the page as the server sends it.
|
||||
/// A link shared from an app other than Safari: the page as the server sends it,
|
||||
/// or the bare link when the page cannot be read (a sign-in wall, an app-only
|
||||
/// page, no network), which the app can still keep among the trip's links.
|
||||
private static func page(at url: URL) async throws -> ShareItem {
|
||||
guard url.scheme == "https" || url.scheme == "http" else { throw ShareLoadError.unsupported }
|
||||
guard let address = webAddress(url) else { throw ShareLoadError.unsupported }
|
||||
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
(http.mimeType ?? "").contains("html") else { throw ShareLoadError.unsupported }
|
||||
let html = String(decoding: data, as: UTF8.self)
|
||||
let title = pageTitle(html) ?? url.host ?? "Page"
|
||||
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
||||
data: data, title: title, source: url.host))
|
||||
if let fetched = try? await URLSession.shared.data(for: request),
|
||||
let http = fetched.1 as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
(http.mimeType ?? "").contains("html"), !fetched.0.isEmpty, fetched.0.count <= maxBytes {
|
||||
let data = fetched.0
|
||||
let html = String(decoding: data, as: UTF8.self)
|
||||
let title = pageTitle(html) ?? url.host ?? "Page"
|
||||
return ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
||||
data: data, title: title, source: url.host, url: address)
|
||||
}
|
||||
return link(url, address)
|
||||
}
|
||||
|
||||
/// The link alone, as a text/uri-list the app recognises as nothing to read a booking from.
|
||||
private static func link(_ url: URL, _ address: String) -> ShareItem {
|
||||
ShareItem(kind: .page, name: "link.url", mimeType: "text/uri-list", data: Data(address.utf8),
|
||||
title: url.host ?? address, source: url.host, url: address)
|
||||
}
|
||||
|
||||
/// An http(s) address, the only kind a trip's links take.
|
||||
private static func webAddress(_ url: URL) -> String? {
|
||||
guard url.scheme == "https" || url.scheme == "http", url.host != nil else { return nil }
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
private static func fromFile(_ url: URL) throws -> ShareItem {
|
||||
|
||||
@@ -100,9 +100,27 @@ final class WatchLink: NSObject, WCSessionDelegate {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
}
|
||||
|
||||
/// A tick to the phone. In reach it goes as a message, which arrives at once and
|
||||
/// wakes the phone app for it, so the trip's other devices see it within seconds.
|
||||
/// Out of reach, or when the message fails or a phone app too old to take it
|
||||
/// answers without "queued", it goes as user info, which the system delivers later.
|
||||
func send(_ action: PendingAction) {
|
||||
guard WCSession.default.activationState == .activated else { return }
|
||||
WCSession.default.transferUserInfo(["action": action.message])
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated else { return }
|
||||
let message: [String: Any] = ["action": action.message]
|
||||
guard session.isReachable else {
|
||||
session.transferUserInfo(message)
|
||||
return
|
||||
}
|
||||
session.sendMessage(message, replyHandler: { reply in
|
||||
if reply["queued"] as? Bool == true {
|
||||
Task { @MainActor in WatchModel.shared.markSent(action.id) }
|
||||
} else {
|
||||
session.transferUserInfo(message)
|
||||
}
|
||||
}, errorHandler: { _ in
|
||||
session.transferUserInfo(message)
|
||||
})
|
||||
}
|
||||
|
||||
/// Asks the phone for a fresh snapshot while it is in reach (the app answers from what it stored).
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The amount inside a price as the store writes it ("4,90 €", "$4.99",
|
||||
* "CHF 12.90", "1.290 ₽"), and a way to write another amount the same way: the
|
||||
* same currency, the same decimal mark, the same digit grouping. The plan picker
|
||||
* counts a price up to its new value with it, and turns a yearly price into what
|
||||
* it makes a month, without knowing the store's locale.
|
||||
*/
|
||||
interface Amount {
|
||||
/** The amount as a number, in the price's own unit (4.9 for "4,90 €"). */
|
||||
value: number;
|
||||
/** The price written for another amount, in the same format. */
|
||||
render(value: number): string;
|
||||
}
|
||||
|
||||
// Digits with the marks stores put between them: . , any space (\s takes the
|
||||
// no-break ones too) and the apostrophes Swiss formats group thousands with.
|
||||
const NUMBER = /\d[\d.,\s'’]*\d|\d/;
|
||||
|
||||
export function amountIn(label: string): Amount | null {
|
||||
const match = NUMBER.exec(label);
|
||||
if (!match) return null;
|
||||
const raw = match[0];
|
||||
// The decimal mark is the last . or , followed by one or two digits to the end.
|
||||
const decimal = /([.,])(\d{1,2})$/.exec(raw);
|
||||
const decimals = decimal ? decimal[2].length : 0;
|
||||
const mark = decimal ? decimal[1] : '';
|
||||
const whole = decimal ? raw.slice(0, raw.length - decimal[0].length) : raw;
|
||||
const group = /[.,\s'’]/.exec(whole)?.[0] ?? '';
|
||||
const digits = whole.replace(/\D/g, '') + (decimal ? `.${decimal[2]}` : '');
|
||||
const value = Number(digits);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const before = label.slice(0, match.index);
|
||||
const after = label.slice(match.index + raw.length);
|
||||
return {
|
||||
value,
|
||||
render(next: number) {
|
||||
const fixed = Math.max(0, next).toFixed(decimals);
|
||||
const [int, frac] = fixed.split('.');
|
||||
const grouped = group ? int.replace(/\B(?=(\d{3})+(?!\d))/g, group) : int;
|
||||
return `${before}${grouped}${frac !== undefined ? mark + frac : ''}${after}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#fffaf5" />
|
||||
<title>TREK</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+1274
-487
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* The landscape behind the first screens of the app: one world seen from above,
|
||||
* an island out at sea, the coast with its town, the farmland behind it and the
|
||||
* mountains at the back. A fragment shader draws it every frame (water, surf,
|
||||
* boats, a car on the road), so it is never a photo and never the same twice.
|
||||
*
|
||||
* The plan picker moves the camera through that world, one place per plan, so
|
||||
* choosing reads as travelling further inland. The scenes are framed for a phone
|
||||
* 390 px wide: a wider or taller box (iPad, an unfolded phone) shows more of the
|
||||
* world around the same centre.
|
||||
*
|
||||
* Kept light for the phone it runs on: the device pixel ratio is capped at 2,
|
||||
* the picture drops to a lower resolution when frames come slow, the loop stops
|
||||
* while the app is in the background or the canvas has left the page, and with
|
||||
* reduced motion it is one still frame. Without WebGL the box keeps its plain
|
||||
* sea colour from the stylesheet.
|
||||
*/
|
||||
|
||||
/** Where the camera stands for a place, in world pixels, for a 390 px frame. */
|
||||
export type Camera = readonly [number, number];
|
||||
export const WELCOME_CAMERA: Camera = [0, 0];
|
||||
/** One place per plan, in the order the plans come: island, coast, farmland, mountains. */
|
||||
const PLAN_CAMERAS: readonly Camera[] = [
|
||||
[62, -392],
|
||||
[0, 24],
|
||||
[60, 290],
|
||||
[70, 660],
|
||||
];
|
||||
export function cameraForPlan(index: number): Camera {
|
||||
return PLAN_CAMERAS[Math.max(0, Math.min(PLAN_CAMERAS.length - 1, index))];
|
||||
}
|
||||
|
||||
const FRAME_WIDTH = 390;
|
||||
|
||||
const FRAGMENT = `
|
||||
precision highp float;
|
||||
uniform vec2 uRes; uniform float uDpr; uniform float uT; uniform vec2 uCam; uniform float uHaze;
|
||||
float coast(float x) { return 128.0 + 12.0 * sin(x * 0.018 + 0.6) + 7.0 * sin(x * 0.051 + 2.1) + 4.0 * sin(x * 0.11); }
|
||||
float riverX(float y) { return 168.0 - (y - 120.0) * 0.30 + 9.0 * sin(y * 0.035 + 0.8) + 4.0 * sin(y * 0.09); }
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
||||
vec2 hash2(vec2 p) { return fract(sin(vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)))) * 43758.5453); }
|
||||
float noise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y); }
|
||||
float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a * noise(p); p = p * 2.03 + vec2(1.7, 9.2); a *= 0.5; } return v; }
|
||||
float fbm3(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 3; i++) { v += a * noise(p); p = p * 2.03 + vec2(1.7, 9.2); a *= 0.5; } return v; }
|
||||
float wave(vec2 p, float t) {
|
||||
return 0.55 * sin(dot(p, vec2(0.040, 0.085)) - t * 1.10) + 0.35 * sin(dot(p, vec2(-0.070, 0.050)) - t * 1.45)
|
||||
+ 0.22 * sin(dot(p, vec2(0.110, 0.130)) - t * 2.10) + 0.12 * sin(dot(p, vec2(-0.170, 0.190)) - t * 2.90)
|
||||
+ 0.90 * fbm(p * 0.055 + vec2(t * 0.18, t * 0.32));
|
||||
}
|
||||
float ridged(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { float n = 1.0 - abs(noise(p) * 2.0 - 1.0); v += a * n * n; p = p * 2.07 + vec2(3.1, 1.7); a *= 0.5; } return v; }
|
||||
/* Height: gentle near the shore, sharp ridges further inland. */
|
||||
float peaks(vec2 p) { float v = 0.0, a = 0.55; for (int i = 0; i < 7; i++) { v += a * (1.0 - abs(noise(p) * 2.0 - 1.0)); p = p * 2.13 + vec2(1.3, 4.7); a *= 0.5; } return v; }
|
||||
float ground(vec2 p) {
|
||||
float inland = smoothstep(10.0, 170.0, p.y - coast(p.x));
|
||||
return fbm(p * 0.011) * 0.4 + ridged(p * 0.014 + 5.0) * 1.15 * inland + fbm3(p * 0.06) * 0.08;
|
||||
}
|
||||
/* The island out at sea: signed distance to its shore in px (> 0 on land),
|
||||
a round main body with an uneven coast and a headland to the east. */
|
||||
float islD(vec2 p) {
|
||||
vec2 q = (p - vec2(230.0, -250.0)) / vec2(1.5, 1.0);
|
||||
float r = length(q);
|
||||
float rad = 66.0 + 46.0 * (fbm(q * 0.016 + 4.0) - 0.5) + 30.0 * (fbm(q * 0.045 + 8.0) - 0.5) + 10.0 * (fbm(p * 0.09) - 0.5);
|
||||
vec2 q2 = (p - vec2(372.0, -222.0)) / vec2(1.6, 1.0);
|
||||
float head = 15.0 + 10.0 * (fbm(p * 0.04 + 9.0) - 0.5) - length(q2);
|
||||
return max(rad - r, head);
|
||||
}
|
||||
float islH(vec2 p) {
|
||||
float s = islD(p);
|
||||
return smoothstep(0.0, 60.0, s) * (0.5 + 0.7 * fbm(p * 0.018 + 2.0)) + ridged(p * 0.03) * 0.22 * smoothstep(8.0, 50.0, s);
|
||||
}
|
||||
/* Trees: one per cell at most, a round crown of varying size. Returns the
|
||||
crown's cover (0..1) and how far from its centre, for the shading. */
|
||||
vec3 tree(vec2 p, float cell) {
|
||||
vec2 c = floor(p / cell);
|
||||
float best = 9.0; vec2 off = vec2(0.0); float sz = 0.0;
|
||||
for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) {
|
||||
vec2 g = c + vec2(float(i), float(j));
|
||||
vec2 h = hash2(g);
|
||||
vec2 o = (g + 0.15 + 0.7 * h) * cell;
|
||||
float r = cell * (0.38 + 0.22 * hash(g + 3.1));
|
||||
float d = length(p - o) / r;
|
||||
if (d < best) { best = d; off = (p - o) / r; sz = r; }
|
||||
}
|
||||
return vec3(1.0 - smoothstep(0.86, 1.0, best), off);
|
||||
}
|
||||
/* A Mediterranean town: one house per cell, a rectangle turned the way
|
||||
the lane runs there (the direction drifts across town, so lanes curve),
|
||||
sized at random and packed close. Whitewashed cubes with a parapet and
|
||||
now and then a water tank, pastel walls, terracotta roofs pitched along
|
||||
the long side, and the odd blue dome. */
|
||||
float town(vec2 p, out vec3 roof, out vec2 hid) {
|
||||
float cell = 9.0;
|
||||
vec2 c = floor(p / cell);
|
||||
roof = vec3(0.0);
|
||||
hid = c;
|
||||
float got = 0.0;
|
||||
for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) {
|
||||
vec2 g = c + vec2(float(i), float(j));
|
||||
if (hash(g + 2.7) < 0.07) continue;
|
||||
vec2 o = (g + 0.5 + 0.35 * (hash2(g) - 0.5)) * cell;
|
||||
float ang = (noise(o * 0.018) - 0.5) * 2.4 + (hash(g + 7.7) - 0.5) * 0.25;
|
||||
vec2 cs = vec2(cos(ang), sin(ang));
|
||||
vec2 rel = p - o;
|
||||
vec2 l = vec2(dot(rel, cs), dot(rel, vec2(-cs.y, cs.x)));
|
||||
vec2 hs = cell * vec2(0.36 + 0.16 * hash(g + 1.3), 0.27 + 0.14 * hash(g + 4.1));
|
||||
if (abs(l.x) < hs.x && abs(l.y) < hs.y) {
|
||||
float k = hash(g + 5.3);
|
||||
vec3 col = k < 0.44 ? vec3(0.96, 0.95, 0.91) : k < 0.68 ? vec3(0.80, 0.46, 0.32) : k < 0.80 ? vec3(0.93, 0.79, 0.56) : k < 0.90 ? vec3(0.94, 0.75, 0.67) : vec3(0.88, 0.86, 0.80);
|
||||
float rim = min(hs.x - abs(l.x), hs.y - abs(l.y));
|
||||
if (k >= 0.44 && k < 0.68) {
|
||||
// pitched roof: the ridge runs along the long side, one slope lit, one shaded
|
||||
float lit = dot(vec2(-cs.y, cs.x), vec2(-0.67, -0.73)) * sign(l.y);
|
||||
col *= lit > 0.0 ? 1.08 : 0.80;
|
||||
col *= 0.95 + 0.05 * sin(l.x * 3.6);
|
||||
col *= 1.0 - 0.25 * (1.0 - smoothstep(0.0, 0.5, abs(l.y)));
|
||||
} else {
|
||||
// flat roof: a parapet round the edge, a water tank on some
|
||||
col *= 1.0 - 0.13 * (1.0 - smoothstep(0.4, 1.1, rim));
|
||||
vec2 tk = vec2(hs.x * 0.45, -hs.y * 0.35);
|
||||
if (hash(g + 6.2) < 0.35 && length(l - tk) < 0.95) col = vec3(0.45, 0.47, 0.50) + 0.25 * step(0.0, -(l - tk).x - (l - tk).y);
|
||||
if (k < 0.44 && hash(g + 4.4) < 0.08 && length(l) < min(hs.x, hs.y) * 0.85) {
|
||||
col = mix(vec3(0.12, 0.30, 0.64), vec3(0.58, 0.76, 0.98), clamp(0.55 - dot(rel / (min(hs.x, hs.y) * 0.85), vec2(-0.67, -0.73)) * 0.6, 0.0, 1.0));
|
||||
}
|
||||
}
|
||||
col *= 0.93 + 0.1 * hash(g + 9.9);
|
||||
roof = col;
|
||||
hid = g;
|
||||
got = 1.0;
|
||||
}
|
||||
}
|
||||
return got;
|
||||
}
|
||||
void main() {
|
||||
vec2 sp = vec2(gl_FragCoord.x, uRes.y - gl_FragCoord.y) / uDpr;
|
||||
vec2 p = sp + uCam;
|
||||
float t = uT;
|
||||
vec3 L = normalize(vec3(-0.55, -0.6, 0.62));
|
||||
float d = coast(p.x) - p.y; // > 0 at sea, < 0 inland, in px
|
||||
float wash = 6.0 + 4.0 * sin(t * 1.2 + p.x * 0.045) + 3.0 * fbm3(vec2(p.x * 0.08, t * 0.6));
|
||||
vec3 col;
|
||||
if (d > -2.0) {
|
||||
/* ── sea ── */
|
||||
float e = 0.9, h = wave(p, t);
|
||||
vec3 n = normalize(vec3(-(wave(p + vec2(e, 0.0), t) - h) * 1.6, -(wave(p + vec2(0.0, e), t) - h) * 1.6, 1.0));
|
||||
float depth = smoothstep(0.0, 120.0, d);
|
||||
col = mix(vec3(0.47, 0.83, 0.82), vec3(0.17, 0.58, 0.72), smoothstep(0.0, 0.45, depth));
|
||||
col = mix(col, vec3(0.07, 0.33, 0.55), smoothstep(0.45, 1.0, depth));
|
||||
col *= 0.78 + 0.32 * clamp(dot(n, -vec3(L.x, L.y, -L.z)), 0.0, 1.0);
|
||||
vec3 Ls = normalize(vec3(0.45, -0.55, 0.70));
|
||||
vec3 R = reflect(-Ls, n);
|
||||
col += vec3(1.0, 0.97, 0.9) * (pow(clamp(R.z, 0.0, 1.0), 90.0) * 0.45 + pow(clamp(R.z, 0.0, 1.0), 900.0) * step(0.55, noise(p * 0.6 + t * 2.0)) * 1.4);
|
||||
float ca = pow(1.0 - abs(sin(fbm(p * 0.09 + vec2(t * 0.35, -t * 0.25)) * 9.0)), 6.0);
|
||||
col += vec3(0.85, 1.0, 0.95) * ca * 0.22 * (1.0 - smoothstep(0.0, 40.0, d));
|
||||
float runs = smoothstep(0.82, 1.0, sin(d * 0.32 + t * 2.2 + fbm3(p * 0.05) * 3.0)) * (1.0 - smoothstep(6.0, 46.0, d));
|
||||
float lace = smoothstep(0.45, 0.75, fbm3(p * 0.22 + vec2(t * 0.5, 0.0)));
|
||||
float foam = max(1.0 - smoothstep(wash - 4.0, wash + 3.0, d), runs * lace * 0.8);
|
||||
col = mix(col, vec3(0.97, 0.99, 1.0), clamp(foam, 0.0, 1.0) * 0.92);
|
||||
// boats crossing, each in a lane of its own (46 px apart, wider than any
|
||||
// wake), at a speed that swells and eases but never turns back
|
||||
for (int k = 0; k < 2; k++) {
|
||||
float fk = float(k);
|
||||
float dir = fk < 0.5 ? 1.0 : -1.0;
|
||||
float speed = 10.0 + fk * 4.0;
|
||||
float run = speed * t + 14.0 * sin(t * 0.23 + fk * 2.0) + 6.0 * sin(t * 0.61 + fk * 4.0);
|
||||
float lane = 36.0 + fk * 46.0;
|
||||
float drift = 2.6 * sin(t * 0.31 + fk * 1.7);
|
||||
vec2 bp = vec2(mod(fk * 190.0 + run * dir, 480.0) - 45.0, lane + drift);
|
||||
float yaw = 2.6 * 0.31 * cos(t * 0.31 + fk * 1.7) / speed;
|
||||
vec2 q = p - bp;
|
||||
q = vec2(q.x * dir, q.y);
|
||||
q = vec2(q.x + yaw * q.y, q.y - yaw * q.x);
|
||||
float back = -q.x;
|
||||
float open = 0.29 + 0.05 * sin(t * 1.3 + fk * 3.0 + back * 0.05);
|
||||
float spread = back * open;
|
||||
float churn = smoothstep(0.25, 0.65, noise(vec2(back * 0.35 - t * 1.6, q.y * 0.45 + fk * 7.0)));
|
||||
float edgeW = 0.7 + 0.8 * noise(vec2(back * 0.2 + fk * 3.0, t * 0.8));
|
||||
float fade = step(0.0, back) * (1.0 - smoothstep(8.0, 64.0, back));
|
||||
float wake = (1.0 - smoothstep(edgeW * 0.6, edgeW * 1.8, abs(abs(q.y) - spread))) * fade * (0.55 + 0.45 * churn);
|
||||
wake += (1.0 - smoothstep(0.0, 2.4, abs(q.y))) * step(0.0, back) * (1.0 - smoothstep(4.0, 30.0, back)) * 0.7 * churn;
|
||||
col = mix(col, vec3(0.96, 0.98, 1.0), clamp(wake, 0.0, 1.0) * 0.78);
|
||||
float hull = length(q / vec2(6.5, 2.4));
|
||||
col = mix(col, vec3(0.20, 0.30, 0.40), (1.0 - smoothstep(1.0, 1.5, length((q - vec2(-1.0, 1.4)) / vec2(6.5, 2.4)))) * 0.25);
|
||||
col = mix(col, mix(vec3(0.98), vec3(0.80, 0.82, 0.85), step(0.0, q.y)), 1.0 - smoothstep(0.92, 1.0, hull));
|
||||
col = mix(col, vec3(0.55, 0.36, 0.24), 1.0 - smoothstep(0.9, 1.0, length((q - vec2(-1.0, 0.0)) / vec2(2.4, 1.2))));
|
||||
}
|
||||
// the island: a shelf of shallow water, a reef, surf, a beach, a wooded hill
|
||||
if (abs(p.y + 250.0) < 175.0 && abs(p.x - 265.0) < 310.0) {
|
||||
float sI = islD(p);
|
||||
if (sI < 0.0) {
|
||||
float sd = -sI;
|
||||
float shelf = 1.0 - smoothstep(4.0, 78.0, sd);
|
||||
float ripple = 0.5 + 0.5 * sin(dot(p, vec2(0.42, 0.25)) + fbm(p * 0.05) * 6.0);
|
||||
vec3 sh = mix(vec3(0.13, 0.52, 0.66), vec3(0.40, 0.83, 0.81), smoothstep(62.0, 10.0, sd));
|
||||
sh = mix(sh, vec3(0.80, 0.86, 0.74), smoothstep(26.0, 0.0, sd) * 0.4);
|
||||
sh *= 0.95 + 0.08 * ripple * smoothstep(50.0, 4.0, sd);
|
||||
float swell = sin(sd * 0.3 + t * 1.8 + fbm3(p * 0.03) * 4.0);
|
||||
sh *= 1.0 + 0.09 * swell * smoothstep(70.0, 20.0, sd) * smoothstep(0.0, 8.0, sd);
|
||||
float reef = smoothstep(0.57, 0.68, fbm(p * 0.045 + 13.0)) * smoothstep(12.0, 22.0, sd) * (1.0 - smoothstep(46.0, 60.0, sd));
|
||||
sh = mix(sh, vec3(0.18, 0.40, 0.42) * (0.82 + 0.3 * noise(p * 0.5)), reef * 0.75);
|
||||
float lw = 0.85 + 0.3 * clamp(dot(n, -vec3(L.x, L.y, -L.z)), 0.0, 1.0);
|
||||
col = mix(col, sh * lw, shelf);
|
||||
float cs = pow(1.0 - abs(sin(fbm(p * 0.12 + vec2(t * 0.4, -t * 0.3)) * 10.0)), 7.0);
|
||||
col += vec3(0.9, 1.0, 0.95) * cs * 0.24 * smoothstep(45.0, 4.0, sd);
|
||||
// surf breaking along the reef edge, in pieces
|
||||
float reefLine = 44.0 + 12.0 * (fbm(p * 0.02 + 5.0) - 0.5);
|
||||
float brk = (1.0 - smoothstep(0.0, 2.0 + 2.5 * fbm3(p * 0.04 + vec2(t * 0.25, 0.0)), abs(sd - reefLine - 2.5 * sin(t * 0.8 + p.x * 0.03))));
|
||||
brk *= smoothstep(0.35, 0.62, noise(p * 0.08 + vec2(t * 0.6, 0.0))) * smoothstep(0.42, 0.56, fbm(p * 0.03 + 13.0));
|
||||
col = mix(col, vec3(0.96, 0.99, 1.0), brk * 0.6);
|
||||
// waves rolling in, foam lines thinning out as they come, the swash on the sand
|
||||
float lace2 = smoothstep(0.42, 0.72, fbm3(p * 0.25 + vec2(t * 0.4, 0.0)));
|
||||
float roll = smoothstep(0.9, 1.0, swell) * (1.0 - smoothstep(5.0, 26.0, sd));
|
||||
float iw = 4.5 + 3.5 * sin(t * 1.1 + fbm(p * 0.03) * 6.0);
|
||||
float swash = 1.0 - smoothstep(iw - 3.0, iw + 2.5, sd);
|
||||
col = mix(col, vec3(0.97, 0.99, 1.0), clamp(max(swash * (0.55 + 0.45 * lace2), roll * lace2 * 0.8), 0.0, 1.0) * 0.9);
|
||||
} else {
|
||||
float hI = islH(p);
|
||||
vec3 nI = normalize(vec3(-(islH(p + vec2(1.0, 0.0)) - hI) * 110.0, -(islH(p + vec2(0.0, 1.0)) - hI) * 110.0, 1.0));
|
||||
float shI = clamp(dot(nI, normalize(vec3(-L.x, -L.y, L.z))), 0.0, 1.0);
|
||||
float bwI = 5.0 + 15.0 * fbm(p * 0.03 + 2.0);
|
||||
vec3 sandI = vec3(0.95, 0.89, 0.74) + (hash(floor(p * 3.0)) - 0.5) * 0.05;
|
||||
sandI = mix(sandI, vec3(0.76, 0.69, 0.56), (1.0 - smoothstep(0.0, 4.5, sI)) * 0.75);
|
||||
float big = step(0.55, fbm(p * 0.025 + 6.0));
|
||||
vec3 tr = big > 0.5 ? tree(p, 8.0) : tree(p, 4.6);
|
||||
vec3 trS = big > 0.5 ? tree(p - vec2(2.6, 3.0), 8.0) : tree(p - vec2(1.7, 2.0), 4.6);
|
||||
float lit = clamp(0.62 - dot(tr.yz, vec2(L.x, L.y)) * 0.55, 0.0, 1.0);
|
||||
float jung = smoothstep(bwI, bwI + 8.0, sI);
|
||||
vec3 under = mix(vec3(0.46, 0.55, 0.28), vec3(0.28, 0.42, 0.19), noise(p * 0.1)) * (1.0 - 0.35 * trS.x);
|
||||
vec3 canopy = mix(vec3(0.09, 0.27, 0.12), vec3(0.42, 0.62, 0.28), lit) * (0.9 + 0.2 * noise(p * 2.0));
|
||||
vec3 landI = mix(sandI, under, jung);
|
||||
landI *= 1.0 - 0.3 * trS.x * smoothstep(bwI - 4.0, bwI, sI) * (1.0 - jung);
|
||||
landI = mix(landI, canopy, tr.x * smoothstep(bwI - 3.0, bwI + 5.0, sI) * (0.35 + 0.65 * smoothstep(0.22, 0.36, fbm(p * 0.035 + 1.0))));
|
||||
// bare rock on the steep top and boulders where the headland meets the sea
|
||||
float slI = 1.0 - nI.z;
|
||||
landI = mix(landI, vec3(0.62, 0.58, 0.50) * (0.9 + 0.15 * noise(p * 0.8)), smoothstep(0.7, 0.88, slI) * 0.6);
|
||||
vec3 bo = tree(p, 4.0);
|
||||
float blit = clamp(0.6 - dot(bo.yz, vec2(L.x, L.y)) * 0.6, 0.0, 1.0);
|
||||
vec3 rocks = mix(vec3(0.30, 0.29, 0.27), mix(vec3(0.52, 0.50, 0.46), vec3(0.80, 0.77, 0.71), blit), bo.x);
|
||||
float rk = smoothstep(0.6, 0.7, fbm(p * 0.05 + 4.0)) * (1.0 - smoothstep(bwI - 2.0, bwI + 4.0, sI));
|
||||
landI = mix(landI, rocks, rk);
|
||||
landI *= 0.5 + 0.7 * shI;
|
||||
col = mix(vec3(0.90, 0.95, 0.93), landI, smoothstep(0.0, 1.4, sI));
|
||||
}
|
||||
// a sailboat going round, well outside the reef
|
||||
float sa = t * 0.06;
|
||||
vec2 sb = vec2(230.0, -250.0) + vec2(cos(sa) * 1.5, sin(sa)) * 140.0;
|
||||
vec2 tang = normalize(vec2(-sin(sa) * 1.5, cos(sa)));
|
||||
vec2 q = p - sb;
|
||||
q = vec2(dot(q, tang), dot(q, vec2(-tang.y, tang.x)));
|
||||
float back = -q.x;
|
||||
float wk = (1.0 - smoothstep(0.5, 1.6, abs(abs(q.y) - back * 0.22))) * step(0.0, back) * (1.0 - smoothstep(6.0, 44.0, back));
|
||||
col = mix(col, vec3(0.96, 0.98, 1.0), wk * 0.7);
|
||||
col = mix(col, vec3(0.10, 0.22, 0.32), (1.0 - smoothstep(1.0, 1.4, length((q - vec2(-1.5, 2.6)) / vec2(5.0, 3.0)))) * 0.25);
|
||||
col = mix(col, vec3(0.98), 1.0 - smoothstep(0.9, 1.0, length(q / vec2(4.8, 1.7))));
|
||||
float sail = max(abs(q.x + 0.4) - 2.6, abs(q.y + 1.2 - (q.x + 0.4) * 0.15) - 0.7);
|
||||
col = mix(col, vec3(0.88, 0.86, 0.82), 1.0 - smoothstep(-0.2, 0.3, sail));
|
||||
}
|
||||
} else {
|
||||
/* ── land: hillside, macchia, and the town down at the water ── */
|
||||
float e = 1.2, h = ground(p);
|
||||
vec3 n = normalize(vec3(-(ground(p + vec2(e, 0.0)) - h) * 17.0, -(ground(p + vec2(0.0, e)) - h) * 17.0, 1.0));
|
||||
float shade = clamp(dot(n, normalize(vec3(-L.x, -L.y, L.z))), 0.0, 1.0);
|
||||
float slope = 1.0 - n.z;
|
||||
float veg = fbm(p * 0.03 + 2.0);
|
||||
vec3 base = mix(vec3(0.78, 0.72, 0.53), vec3(0.50, 0.56, 0.33), smoothstep(0.42, 0.58, veg));
|
||||
base = mix(base, vec3(0.36, 0.45, 0.25), smoothstep(0.6, 0.7, veg) * 0.8);
|
||||
base *= 0.9 + 0.16 * noise(p * 1.4) + 0.06 * (hash(floor(p * 2.0)) - 0.5);
|
||||
float shrub = smoothstep(0.42, 0.6, fbm(p * 0.022 + 11.0));
|
||||
vec3 shT = tree(p - vec2(2.0, 2.4), 6.0);
|
||||
base *= 1.0 - 0.34 * shT.x * shrub;
|
||||
vec3 tr = tree(p, 6.0);
|
||||
float lit = clamp(0.62 - dot(tr.yz, vec2(L.x, L.y)) * 0.55, 0.0, 1.0);
|
||||
vec3 leaf = mix(vec3(0.16, 0.27, 0.14), vec3(0.45, 0.55, 0.30), lit) * (0.9 + 0.2 * noise(p * 2.3));
|
||||
base = mix(base, leaf, tr.x * shrub * (1.0 - smoothstep(0.35, 0.6, slope)));
|
||||
vec3 limestone = mix(vec3(0.70, 0.66, 0.58), vec3(0.88, 0.85, 0.78), noise(p * 0.7)) * (0.92 + 0.12 * noise(p * 3.1));
|
||||
base = mix(base, limestone, smoothstep(0.3, 0.6, slope) * 0.75);
|
||||
// the town: dense by the water, thinning out up the hill, house by house
|
||||
vec3 roof, tmpRoof; vec2 hid, tmpId;
|
||||
float occ = town(p, roof, hid);
|
||||
float occS = town(p - vec2(2.4, 3.0), tmpRoof, tmpId);
|
||||
float near = exp(d / 150.0);
|
||||
float want = fbm(p * 0.011 + 3.0) * 0.75 * exp(d / 420.0) + near * 0.6;
|
||||
float here = step(0.55 + 0.12 * hash(hid + 6.6), want);
|
||||
float hereS = step(0.55 + 0.12 * hash(tmpId + 6.6), want);
|
||||
// lanes are paved only in the dense heart; where no house stands the hillside shows
|
||||
float dense = smoothstep(0.62, 0.7, want);
|
||||
vec3 stone = vec3(0.87, 0.84, 0.77) * (0.95 + 0.07 * noise(p * 1.6));
|
||||
vec3 open = roof.x < -0.5 ? mix(stone, vec3(0.48, 0.57, 0.34), step(0.5, hash(hid + 3.0))) : stone;
|
||||
col = mix(base, open, dense);
|
||||
if (occ * here > 0.5) col = roof;
|
||||
else if (occS * hereS > 0.5) col *= 0.64;
|
||||
// the hinterland: parcels with their own crop and row direction, hedges between
|
||||
float farm = smoothstep(150.0, 200.0, -d) * (1.0 - smoothstep(450.0, 510.0, -d));
|
||||
if (farm > 0.0) {
|
||||
vec2 fp = (p + vec2(fbm(p * 0.015), fbm(p * 0.015 + 7.0)) * 10.0) * vec2(0.62, 1.0);
|
||||
vec2 fc = floor(fp / 58.0);
|
||||
float b1 = 99.0, b2 = 99.0; vec2 fid = vec2(0.0);
|
||||
for (int fj = -1; fj <= 1; fj++) for (int fi = -1; fi <= 1; fi++) {
|
||||
vec2 g = fc + vec2(float(fi), float(fj));
|
||||
float dd = length(fp - (g + 0.25 + 0.5 * hash2(g)) * 58.0);
|
||||
if (dd < b1) { b2 = b1; b1 = dd; fid = g; } else if (dd < b2) { b2 = dd; }
|
||||
}
|
||||
float kind = hash(fid + 1.7);
|
||||
float ang = hash(fid + 4.2) * 3.1416;
|
||||
vec2 dir = vec2(cos(ang), sin(ang));
|
||||
float u = dot(p, dir), v = dot(p, vec2(-dir.y, dir.x));
|
||||
vec3 parcel;
|
||||
if (kind < 0.36) {
|
||||
float r = smoothstep(-0.2, 0.6, sin(u * 6.2831 / 4.0));
|
||||
vec3 vine = mix(vec3(0.24, 0.36, 0.16), vec3(0.44, 0.54, 0.25), noise(p * 1.4)) * (0.85 + 0.2 * noise(vec2(u * 0.3, v * 1.6)));
|
||||
parcel = mix(vec3(0.67, 0.57, 0.42), vine, r);
|
||||
} else if (kind < 0.56) {
|
||||
parcel = mix(vec3(0.88, 0.78, 0.52), vec3(0.80, 0.68, 0.43), 0.5 + 0.5 * sin(u * 6.2831 / 2.4)) * (0.94 + 0.1 * noise(p * 0.5));
|
||||
} else if (kind < 0.74) {
|
||||
vec2 q = vec2(u, v) / 9.0;
|
||||
vec2 qc = fract(q) - 0.5 + (hash2(floor(q)) - 0.5) * 0.18;
|
||||
float olive = 1.0 - smoothstep(0.2, 0.33, length(qc));
|
||||
float oshadow = 1.0 - smoothstep(0.2, 0.36, length(qc - vec2(0.12, 0.16)));
|
||||
vec3 soil = vec3(0.76, 0.68, 0.52) * (1.0 - 0.28 * oshadow);
|
||||
parcel = mix(soil, mix(vec3(0.36, 0.42, 0.28), vec3(0.56, 0.60, 0.42), clamp(0.6 - dot(qc, vec2(L.x, L.y)) * 1.4, 0.0, 1.0)), olive);
|
||||
} else if (kind < 0.88) {
|
||||
parcel = mix(vec3(0.50, 0.39, 0.28), vec3(0.63, 0.51, 0.37), 0.5 + 0.5 * sin(u * 6.2831 / 1.9)) * (0.95 + 0.08 * noise(p * 0.6));
|
||||
} else {
|
||||
parcel = mix(vec3(0.47, 0.57, 0.29), vec3(0.58, 0.63, 0.33), noise(p * 0.08));
|
||||
}
|
||||
// hedgerows and the odd cypress along the field edges
|
||||
float edgeD = (b2 - b1) * 0.5;
|
||||
parcel *= 0.92 + 0.16 * hash(fid + 5.5);
|
||||
float hedge = 1.0 - smoothstep(0.5, 1.5, edgeD);
|
||||
vec3 cy = tree(p, 5.0);
|
||||
parcel = mix(parcel, vec3(0.30, 0.35, 0.20), hedge * 0.75);
|
||||
parcel = mix(parcel, vec3(0.12, 0.20, 0.10), cy.x * (1.0 - smoothstep(1.5, 3.5, edgeD)) * step(0.7, hash(fid + 9.0)));
|
||||
float level = 1.0 - smoothstep(0.38, 0.6, slope);
|
||||
col = mix(col, parcel, farm * level * (1.0 - step(0.5, occ * here)));
|
||||
// a country road winding through, a car on it
|
||||
float ry = 425.0 + 26.0 * sin(p.x * 0.011 + 1.0) + 9.0 * sin(p.x * 0.029);
|
||||
float rd = abs(p.y - ry);
|
||||
col = mix(col, vec3(0.86, 0.82, 0.72), (1.0 - smoothstep(4.6, 5.6, rd)) * farm);
|
||||
col = mix(col, vec3(0.36, 0.36, 0.37) * (0.95 + 0.08 * noise(p * 2.0)), (1.0 - smoothstep(3.4, 4.2, rd)) * farm);
|
||||
col = mix(col, vec3(0.95), (1.0 - smoothstep(0.25, 0.6, rd)) * step(0.5, fract(p.x / 9.0)) * farm);
|
||||
float cx = mod(t * 22.0 + 6.0 * sin(t * 0.4), 760.0) - 120.0;
|
||||
float cyy = 425.0 + 26.0 * sin(cx * 0.011 + 1.0) + 9.0 * sin(cx * 0.029) - 1.6;
|
||||
float slopeR = 26.0 * 0.011 * cos(cx * 0.011 + 1.0) + 9.0 * 0.029 * cos(cx * 0.029);
|
||||
vec2 cq = p - vec2(cx, cyy);
|
||||
float ca = atan(slopeR);
|
||||
cq = vec2(cq.x * cos(ca) + cq.y * sin(ca), -cq.x * sin(ca) + cq.y * cos(ca));
|
||||
float body = max(abs(cq.x) - 3.4, abs(cq.y) - 1.4);
|
||||
col = mix(col, vec3(0.14, 0.14, 0.15), (1.0 - smoothstep(0.0, 1.4, max(abs(cq.x - 0.8) - 3.6, abs(cq.y - 0.9) - 1.6))) * 0.35);
|
||||
col = mix(col, vec3(0.78, 0.16, 0.13), 1.0 - smoothstep(-0.2, 0.4, body));
|
||||
col = mix(col, vec3(0.20, 0.24, 0.30), (1.0 - smoothstep(-0.2, 0.3, max(abs(cq.x - 0.9) - 1.0, abs(cq.y) - 1.0))));
|
||||
}
|
||||
col *= 0.64 + 0.5 * shade;
|
||||
col *= 1.0 - 0.1 * smoothstep(0.55, 0.0, h);
|
||||
// the high country: broad massifs, conifers in the valley, snow on the tops
|
||||
float alpine = smoothstep(470.0, 590.0, -d);
|
||||
if (alpine > 0.0) {
|
||||
float lakeD = length((p - vec2(250.0, 790.0)) / vec2(2.3, 1.0));
|
||||
float valley = mix(0.35, 1.0, smoothstep(26.0, 80.0, lakeD));
|
||||
float hA = (peaks(p * 0.006 + 5.0) * 1.3 - 0.2 + fbm(p * 0.035) * 0.05) * valley;
|
||||
float hX = (peaks((p + vec2(1.5, 0.0)) * 0.006 + 5.0) * 1.3 - 0.2 + fbm((p + vec2(1.5, 0.0)) * 0.035) * 0.05) * valley;
|
||||
float hY = (peaks((p + vec2(0.0, 1.5)) * 0.006 + 5.0) * 1.3 - 0.2 + fbm((p + vec2(0.0, 1.5)) * 0.035) * 0.05) * valley;
|
||||
vec3 nA = normalize(vec3(-(hX - hA) * 95.0, -(hY - hA) * 95.0, 1.0));
|
||||
float shA = clamp(dot(nA, normalize(vec3(-L.x, -L.y, L.z))), 0.0, 1.0);
|
||||
float slA = 1.0 - nA.z;
|
||||
vec3 rockA = mix(vec3(0.46, 0.43, 0.39), vec3(0.62, 0.58, 0.52), noise(p * 0.05)) * (0.94 + 0.08 * noise(p * 0.9));
|
||||
vec3 trA = tree(p, 4.4);
|
||||
vec3 trS = tree(p - vec2(1.6, 2.0), 4.4);
|
||||
float conifer = (1.0 - smoothstep(0.40, 0.50, hA + (noise(p * 0.06) - 0.5) * 0.08)) * (1.0 - smoothstep(0.5, 0.75, slA));
|
||||
vec3 meadow = mix(vec3(0.42, 0.50, 0.28), vec3(0.52, 0.56, 0.32), noise(p * 0.1));
|
||||
vec3 forestA = meadow * (1.0 - 0.4 * trS.x);
|
||||
forestA = mix(forestA, mix(vec3(0.07, 0.15, 0.09), vec3(0.20, 0.33, 0.18), clamp(0.6 - dot(trA.yz, vec2(L.x, L.y)) * 0.6, 0.0, 1.0)), trA.x * smoothstep(0.2, 0.5, fbm(p * 0.02 + 3.0) + 0.25));
|
||||
vec3 alp = mix(rockA, forestA, conifer);
|
||||
float snowA = smoothstep(0.75, 0.81, hA + (noise(p * 0.09) - 0.5) * 0.1) * (1.0 - smoothstep(0.55, 0.8, slA));
|
||||
snowA = max(snowA, smoothstep(0.86, 0.94, hA));
|
||||
alp *= 0.36 + 0.86 * shA;
|
||||
alp = mix(alp * vec3(0.8, 0.87, 1.05), alp * vec3(1.04, 1.0, 0.95), smoothstep(0.35, 0.8, shA));
|
||||
vec3 snowC = mix(vec3(0.70, 0.77, 0.88), vec3(0.98, 0.98, 0.97), smoothstep(0.2, 0.75, shA)) * (0.96 + 0.05 * noise(p * 0.6));
|
||||
alp = mix(alp, snowC, snowA);
|
||||
col = mix(col, alp, alpine);
|
||||
}
|
||||
// a mountain lake in a valley
|
||||
vec2 lc = vec2(250.0, 790.0);
|
||||
float ld = length((p - lc) / vec2(2.3, 1.0)) + (fbm(p * 0.05) - 0.5) * 14.0;
|
||||
if (ld < 34.0) {
|
||||
float lw = wave(p * 1.3, t * 0.6);
|
||||
vec3 lake = mix(vec3(0.12, 0.36, 0.48), vec3(0.24, 0.55, 0.62), smoothstep(34.0, 12.0, ld));
|
||||
lake *= 0.86 + 0.12 * lw;
|
||||
lake += vec3(1.0, 0.98, 0.92) * pow(noise(p * 0.6 + t * 1.2), 14.0) * 0.7;
|
||||
col = mix(col, lake, smoothstep(34.0, 31.0, ld));
|
||||
col = mix(col, vec3(0.82, 0.80, 0.74), (1.0 - smoothstep(0.0, 1.8, abs(ld - 32.0))) * 0.8);
|
||||
}
|
||||
// the shore: small coves of sand between rocks, wet where the wash runs
|
||||
float bw = 17.0 + 10.0 * fbm3(vec2(p.x * 0.03, 1.0));
|
||||
float shore = 1.0 - smoothstep(bw - 3.0, bw + 2.0, -d);
|
||||
float rock = smoothstep(0.56, 0.66, fbm(p * 0.05 + 4.0));
|
||||
vec3 sand = vec3(0.93, 0.86, 0.71) + (hash(floor(p * 3.0)) - 0.5) * 0.06;
|
||||
float wet = 1.0 - smoothstep(0.0, 6.0 + wash * 0.4, -d);
|
||||
sand = mix(sand, vec3(0.74, 0.67, 0.55), wet * 0.8);
|
||||
// boulders: round stones lit from the top left, darker in the cracks between
|
||||
vec3 bo = tree(p, 4.5);
|
||||
float blit = clamp(0.6 - dot(bo.yz, vec2(L.x, L.y)) * 0.6, 0.0, 1.0);
|
||||
vec3 rocks = mix(vec3(0.30, 0.29, 0.27), mix(vec3(0.52, 0.50, 0.46), vec3(0.80, 0.77, 0.71), blit), bo.x);
|
||||
vec3 edgeCol = mix(sand, rocks, rock);
|
||||
col = mix(col, edgeCol, shore);
|
||||
}
|
||||
// haze into the page below: a long, uneven edge that drifts slowly
|
||||
float H = uRes.y / uDpr;
|
||||
float edge = H - 118.0 + 42.0 * (fbm3(vec2(sp.x * 0.011, t * 0.04)) - 0.5) * 2.0 + 22.0 * (fbm(sp * 0.028 + vec2(t * 0.05, 0.0)) - 0.5) * 2.0;
|
||||
float mist = smoothstep(edge - 34.0, edge + 84.0, sp.y);
|
||||
mist = clamp(mist + smoothstep(H - 24.0, H, sp.y), 0.0, 1.0);
|
||||
col = mix(col, vec3(1.0, 0.98, 0.961), mist * uHaze);
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`;
|
||||
|
||||
const VERTEX = 'attribute vec2 a; void main(){ gl_Position = vec4(a, 0.0, 1.0); }';
|
||||
|
||||
export interface Scenery {
|
||||
/** Glides the camera to another place; a still picture jumps there. */
|
||||
flyTo(camera: Camera): void;
|
||||
}
|
||||
|
||||
interface SceneryOptions {
|
||||
/** The height the scene is framed for; the box may be taller or shorter. */
|
||||
frame: number;
|
||||
camera?: Camera;
|
||||
/** The soft fade into the page at the foot of the picture; off beside a column. */
|
||||
haze?: boolean;
|
||||
}
|
||||
|
||||
const reducedMotion = (): boolean =>
|
||||
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
export function startScenery(box: HTMLElement, opts: SceneryOptions): Scenery | null {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'scenery';
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
box.prepend(canvas);
|
||||
const gl = canvas.getContext('webgl', { antialias: false, premultipliedAlpha: false, powerPreference: 'low-power' });
|
||||
if (!gl) {
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
const compile = (type: number, src: string): WebGLShader | null => {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
gl.shaderSource(shader, src);
|
||||
gl.compileShader(shader);
|
||||
return shader;
|
||||
};
|
||||
const program = gl.createProgram();
|
||||
const vs = compile(gl.VERTEX_SHADER, VERTEX);
|
||||
const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);
|
||||
if (!program || !vs || !fs) {
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
gl.useProgram(program);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
|
||||
const position = gl.getAttribLocation(program, 'a');
|
||||
gl.enableVertexAttribArray(position);
|
||||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
||||
const uRes = gl.getUniformLocation(program, 'uRes');
|
||||
const uDpr = gl.getUniformLocation(program, 'uDpr');
|
||||
const uT = gl.getUniformLocation(program, 'uT');
|
||||
const uCam = gl.getUniformLocation(program, 'uCam');
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'uHaze'), opts.haze === false ? 0 : 1);
|
||||
|
||||
// The picture's resolution: the screen's, capped at 2, scaled down when frames come slow.
|
||||
let quality = 1;
|
||||
let offset: [number, number] = [0, 0];
|
||||
const resize = () => {
|
||||
const cssWidth = box.clientWidth || FRAME_WIDTH;
|
||||
const cssHeight = box.clientHeight || opts.frame;
|
||||
const ratio = Math.min(2, Math.max(1, window.devicePixelRatio || 1)) * quality;
|
||||
canvas.width = Math.max(1, Math.round(cssWidth * ratio));
|
||||
canvas.height = Math.max(1, Math.round(cssHeight * ratio));
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.uniform2f(uRes, canvas.width, canvas.height);
|
||||
gl.uniform1f(uDpr, canvas.width / cssWidth);
|
||||
// The scene is framed for a phone: a bigger box shows more around the same
|
||||
// centre, a smaller one (a small iPhone) the middle of it.
|
||||
offset = [(cssWidth - FRAME_WIDTH) / 2, (cssHeight - opts.frame) / 2];
|
||||
};
|
||||
resize();
|
||||
|
||||
const still = reducedMotion();
|
||||
const born = performance.now();
|
||||
let camera: [number, number] = [opts.camera?.[0] ?? 0, opts.camera?.[1] ?? 0];
|
||||
let flight: { from: [number, number]; to: Camera; start: number; duration: number } | null = null;
|
||||
let running = false;
|
||||
let lastFrame = 0;
|
||||
let measured = 0;
|
||||
let slowFrames = 0;
|
||||
|
||||
const draw = (now: number) => {
|
||||
if (flight) {
|
||||
const k = Math.min(1, (now - flight.start) / flight.duration);
|
||||
const e = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
|
||||
camera = [
|
||||
flight.from[0] + (flight.to[0] - flight.from[0]) * e,
|
||||
flight.from[1] + (flight.to[1] - flight.from[1]) * e,
|
||||
];
|
||||
if (k >= 1) flight = null;
|
||||
}
|
||||
gl.uniform2f(uCam, camera[0] - offset[0], camera[1] - offset[1]);
|
||||
gl.uniform1f(uT, still ? 4 : (now - born) / 1000 + 4);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
};
|
||||
|
||||
const frame = (now: number) => {
|
||||
if (!canvas.isConnected || document.hidden) {
|
||||
running = false;
|
||||
return;
|
||||
}
|
||||
// A device that cannot keep up gets a smaller picture once, rather than a stutter.
|
||||
if (lastFrame && quality === 1 && measured < 90) {
|
||||
measured += 1;
|
||||
if (now - lastFrame > 24) slowFrames += 1;
|
||||
if (measured === 90 && slowFrames > 45) {
|
||||
quality = 0.7;
|
||||
resize();
|
||||
}
|
||||
}
|
||||
lastFrame = now;
|
||||
draw(now);
|
||||
if (!still || flight) requestAnimationFrame(frame);
|
||||
else running = false;
|
||||
};
|
||||
const run = () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
lastFrame = 0;
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
run();
|
||||
|
||||
// The loop stops in the background (document.hidden) and starts again on return.
|
||||
const wake = () => {
|
||||
if (!document.hidden && canvas.isConnected) run();
|
||||
};
|
||||
document.addEventListener('visibilitychange', wake);
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!canvas.isConnected) {
|
||||
observer.disconnect();
|
||||
document.removeEventListener('visibilitychange', wake);
|
||||
return;
|
||||
}
|
||||
resize();
|
||||
if (still) run();
|
||||
});
|
||||
observer.observe(box);
|
||||
}
|
||||
canvas.addEventListener('webglcontextlost', (event) => {
|
||||
event.preventDefault();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
return {
|
||||
flyTo(to: Camera) {
|
||||
if (still) {
|
||||
camera = [to[0], to[1]];
|
||||
run();
|
||||
return;
|
||||
}
|
||||
const distance = Math.hypot(to[0] - camera[0], to[1] - camera[1]);
|
||||
flight = {
|
||||
from: [camera[0], camera[1]],
|
||||
to,
|
||||
start: performance.now(),
|
||||
duration: Math.min(1700, 700 + distance * 1.1),
|
||||
};
|
||||
run();
|
||||
},
|
||||
};
|
||||
}
|
||||
+1211
-1034
File diff suppressed because it is too large
Load Diff
+25
-22
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.1.0",
|
||||
"version": "5.1.4",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -18,6 +18,7 @@
|
||||
"store:graphic": "node scripts/generate-feature-graphic.mjs",
|
||||
"store:check": "node scripts/check-store-metadata.mjs",
|
||||
"build": "vite build",
|
||||
"postbuild": "node scripts/precompress.mjs",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -52,13 +53,15 @@
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@capacitor/android": "^8.5.0",
|
||||
"@assistant-ui/react": "0.15.32",
|
||||
"@capacitor/action-sheet": "^8.1.1",
|
||||
"@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",
|
||||
@@ -103,19 +106,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",
|
||||
@@ -129,41 +132,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import touchHover from './postcss/touchHover.js';
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
// touchHover last: it guards the :hover rules Tailwind has just written too.
|
||||
plugins: [tailwindcss(), autoprefixer(), touchHover()],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Hover styles only while a pointer drives the UI.
|
||||
*
|
||||
* A finger has no hover, yet WebKit and Chrome apply :hover to whatever was
|
||||
* tapped and keep it there until the next tap lands somewhere else. In the
|
||||
* iOS and Android app that left buttons and rows tinted after every tap, a
|
||||
* tell of a web page that no native app has. The app marks <html> with
|
||||
* data-touch-input while a finger is in use (src/native/touchInput.ts), and
|
||||
* this plugin makes every :hover rule, Tailwind's and our own, wait for that
|
||||
* mark to be gone. A browser never sets it, so nothing changes there, and an
|
||||
* iPad with a trackpad clears it on the first pointer move.
|
||||
*
|
||||
* The condition sits on the hovered element itself, wrapped in :where(), so a
|
||||
* rule keeps its specificity and still wins or loses where it did before. A
|
||||
* :hover after a pseudo-element (::-webkit-slider-thumb:hover) stays as it is:
|
||||
* nothing may follow it there, and a rule with an invalid selector is dropped
|
||||
* whole.
|
||||
*/
|
||||
|
||||
export const TOUCH_INPUT_ATTR = 'data-touch-input';
|
||||
const GUARD = `:where(:root:not([${TOUCH_INPUT_ATTR}]) *)`;
|
||||
// :hover itself, not the escaped `hover\:` inside a Tailwind class name, and not :hover-something.
|
||||
const HOVER = /(?<!\\):hover(?![\w-])/g;
|
||||
const COMPOUND_START = /[\s>+~(,]/;
|
||||
|
||||
function compoundBefore(selector, index) {
|
||||
let i = index - 1;
|
||||
while (i >= 0 && !COMPOUND_START.test(selector[i])) i--;
|
||||
return selector.slice(i + 1, index);
|
||||
}
|
||||
|
||||
export function guardHover(selector) {
|
||||
if (selector.includes(GUARD)) return selector;
|
||||
return selector.replace(HOVER, (match, offset) =>
|
||||
compoundBefore(selector, offset).includes('::') ? match : `${match}${GUARD}`
|
||||
);
|
||||
}
|
||||
|
||||
export default function touchHover() {
|
||||
return {
|
||||
postcssPlugin: 'trek-touch-hover',
|
||||
Rule(rule) {
|
||||
if (!rule.selector.includes(':hover')) return;
|
||||
const next = rule.selectors.map(guardHover);
|
||||
if (next.some((s, i) => s !== rule.selectors[i])) rule.selectors = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
touchHover.postcss = true;
|
||||
@@ -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: '/' });
|
||||
});
|
||||
})();
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 375,
|
||||
"@typescript-eslint/no-misused-promises": 845,
|
||||
"@typescript-eslint/no-misused-promises": 843,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
"@typescript-eslint/no-explicit-any": 144,
|
||||
|
||||
@@ -834,6 +834,12 @@
|
||||
"desktop": ["src/components/shared/Toast.tsx"],
|
||||
"mobile": ["src/mobile/components/MToastHost.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tour editor workspace",
|
||||
"hooks": ["src/pages/tripPlanner/useTripPlannerPage.ts"],
|
||||
"desktop": ["src/pages/TripPlannerPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tours/MTourWorkspace.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tours list filter",
|
||||
"hooks": ["src/components/Tours/tourPresentation.ts"],
|
||||
@@ -1022,6 +1028,7 @@
|
||||
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
|
||||
"src/mobile/screens/journey/MJourneyFeed.tsx": "Markup only; the journey's entries as a feed beside the map on a wide window, with the entries, the active one and the tap handed in through props from useJourneyDetail and useJourneyCarouselSync.",
|
||||
"src/mobile/screens/settings/MAppLockCard.tsx": "Phone only: the app lock exists only in the native app, so there is no desktop setting to share with.",
|
||||
"src/mobile/screens/settings/MClientCertCard.tsx": "Phone only: the client certificate is kept by the native app, so there is no desktop setting to share with.",
|
||||
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
||||
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
||||
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
|
||||
@@ -1032,6 +1039,7 @@
|
||||
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
|
||||
"src/mobile/screens/settings/MTileCacheUsage.tsx": "Phone only: the native tile cache size and clear action, which the browser build has no counterpart for.",
|
||||
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
|
||||
"src/mobile/screens/trip/tabHeadSlot.ts": "Phone shell only; the place in the shell's top row the tours tab and the journal fill with their own switches. The desktop heads its panels itself.",
|
||||
"src/mobile/screens/trip/MTripRail.tsx": "Phone shell only; the trip's sections on the leading edge of a wide window in place of the dock, with the tabs and handlers MTripShell hands in.",
|
||||
"src/mobile/screens/trip/MTripShellParts.tsx": "Markup only; small pieces of MTripShell's top controls (segment pill, compact toggle, document sync button), with the state and handlers MTripShell hands in.",
|
||||
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"shared.ts": 2,
|
||||
"storage.ts": 16,
|
||||
"system_notice.ts": 32,
|
||||
"todo.ts": 3,
|
||||
"todo.ts": 2,
|
||||
"tours.ts": 12,
|
||||
"trip.ts": 10,
|
||||
"undo.ts": 1,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* The work behind scripts/precompress.mjs: a Brotli copy (<file>.br) beside every
|
||||
* compressible build file under dist/assets.
|
||||
*
|
||||
* The server compresses a build file on every download otherwise, at the low level
|
||||
* a request path can afford. A file under assets/ carries its content hash in its
|
||||
* name and never changes, so it is compressed once here, at the level a build can
|
||||
* afford, and the server hands that copy out as it is
|
||||
* (server/src/nest/platform/precompressed-static.ts).
|
||||
*/
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { brotliCompressSync, constants } from 'node:zlib';
|
||||
|
||||
/** What the server's own compression would take; fonts, images and video are compressed formats already. */
|
||||
const COMPRESSIBLE = /\.(?:m?js|css|svg|json|wasm|html|webmanifest)$/i;
|
||||
/** The server leaves anything below 1 kB uncompressed too: the headers cost more than the saving. */
|
||||
export const MIN_BYTES = 1024;
|
||||
/** Quality 11 takes about ten times as long per byte as 9, so the largest chunks get 9. */
|
||||
const BIG_BYTES = 1024 * 1024;
|
||||
|
||||
function* walk(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full);
|
||||
else yield full;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the .br copies under `dir` and returns what it did. A copy that would not be
|
||||
* smaller than its file is not written, and a directory that does not exist is no work.
|
||||
*/
|
||||
export function precompress(dir) {
|
||||
const result = { files: 0, before: 0, after: 0 };
|
||||
let files;
|
||||
try {
|
||||
files = [...walk(dir)];
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return result;
|
||||
throw err;
|
||||
}
|
||||
for (const file of files) {
|
||||
if (!COMPRESSIBLE.test(file)) continue;
|
||||
const input = readFileSync(file);
|
||||
if (input.length < MIN_BYTES) continue;
|
||||
const output = brotliCompressSync(input, {
|
||||
params: {
|
||||
[constants.BROTLI_PARAM_QUALITY]: input.length > BIG_BYTES ? 9 : 11,
|
||||
[constants.BROTLI_PARAM_SIZE_HINT]: input.length,
|
||||
},
|
||||
});
|
||||
if (output.length >= input.length) continue;
|
||||
writeFileSync(`${file}.br`, output);
|
||||
result.files += 1;
|
||||
result.before += input.length;
|
||||
result.after += output.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* postbuild: writes a Brotli copy beside every compressible file under dist/assets,
|
||||
* which the server hands to a browser that accepts Brotli instead of compressing
|
||||
* the file again on each download. scripts/lib/precompress.mjs holds the work.
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { precompress } from './lib/precompress.mjs';
|
||||
|
||||
const { files, before, after } = precompress(fileURLToPath(new URL('../dist/assets', import.meta.url)));
|
||||
const mb = (bytes) => (bytes / 1048576).toFixed(1);
|
||||
console.log(`precompress: ${files} files, ${mb(before)} MB -> ${mb(after)} MB (brotli)`);
|
||||
@@ -24,8 +24,8 @@
|
||||
"components/Map/DawarichTrailPill.tsx": 1,
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 6,
|
||||
"components/Map/MapViewGL.tsx": 8,
|
||||
"components/Map/MapView.tsx": 5,
|
||||
"components/Map/MapViewGL.tsx": 7,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
"components/Map/PoiCategoryPill.tsx": 1,
|
||||
@@ -34,6 +34,7 @@
|
||||
"components/Map/locationMarkerMapbox.ts": 2,
|
||||
"components/Map/nightPauseMarker.ts": 4,
|
||||
"components/Map/ratingBadge.ts": 1,
|
||||
"components/Map/switcherRow.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 7,
|
||||
"components/PDF/tripMapImage.ts": 1,
|
||||
"components/Packing/PackingListPanelBagCard.tsx": 1,
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1201,
|
||||
"src/components/Map/MapView.tsx": 1200,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2127,
|
||||
"src/components/Map/MapViewGL.tsx": 2126,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2910,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1287,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
@@ -18,7 +18,7 @@
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1506,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4028,
|
||||
"src/pages/LoginPage.tsx": 1347,
|
||||
"src/pages/LoginPage.tsx": 1294,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/atlas/useAtlas.ts": 1207,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
|
||||
@@ -42,12 +42,11 @@
|
||||
"src/components/Map/MapViewGL.tsx": 1,
|
||||
"src/components/Map/ReservationOverlay.test.tsx": 1,
|
||||
"src/components/Map/transitGeometry.ts": 1,
|
||||
"src/components/Map/useDawarichTrailGL.test.ts": 4,
|
||||
"src/components/Map/useMarkerThumbs.ts": 1,
|
||||
"src/components/PDF/TripPDF.tsx": 40,
|
||||
"src/components/Packing/PackingListPanelItemRow.test.tsx": 2,
|
||||
"src/components/Planner/DayPlanSidebar.constants.ts": 1,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 61,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 52,
|
||||
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 3,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1,
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapPluginMarkers.tsx": 2,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 8,
|
||||
"components/Map/MapViewGL.tsx": 11,
|
||||
"components/Map/MapView.tsx": 7,
|
||||
"components/Map/MapViewGL.tsx": 10,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
"components/Map/ReservationOverlay.tsx": 5,
|
||||
@@ -71,6 +71,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Map/switcherRow.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 51,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -293,7 +294,7 @@
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
"pages/JourneyPublicPage.tsx": 113,
|
||||
"pages/LoginPage.tsx": 58,
|
||||
"pages/LoginPage.tsx": 54,
|
||||
"pages/OAuthAuthorizePage.tsx": 33,
|
||||
"pages/PluginPage.tsx": 1,
|
||||
"pages/ResetPasswordPage.tsx": 23,
|
||||
@@ -301,6 +302,7 @@
|
||||
"pages/VacayPage.tsx": 13,
|
||||
"pages/atlas/useAtlas.ts": 2,
|
||||
"pages/collections/useCollections.ts": 1,
|
||||
"pages/login/MfaCodeStep.tsx": 4,
|
||||
"pages/sharedTrip/SharedChrome.tsx": 2,
|
||||
"pages/sharedTrip/SharedMap.tsx": 1,
|
||||
"pages/tripPlanner/ListsContainer.tsx": 1,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"components/Map/DawarichTrailLayer.tsx": 1,
|
||||
"components/Map/dawarichTrail.ts": 10,
|
||||
"components/Map/markerLook.ts": 1,
|
||||
"components/Map/poiCategories.ts": 2,
|
||||
"components/Map/useDawarichTrailGL.ts": 1,
|
||||
"components/Packing/PackingBringersCard.tsx": 1,
|
||||
"components/Planner/DayReorderPopup.tsx": 1,
|
||||
|
||||
@@ -36,7 +36,9 @@ import NewVersionNotice from './components/Layout/NewVersionNotice'
|
||||
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
|
||||
import AppLockGate from './components/Layout/AppLockGate'
|
||||
import NativeImportSheet from './mobile/components/NativeImportSheet'
|
||||
import MPushPrompt from './mobile/components/MPushPrompt'
|
||||
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
|
||||
import NativeQuickActionsSync from './components/Layout/NativeQuickActionsSync'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
import HelpPanel from './components/Help/HelpPanel'
|
||||
// Notice action registrations (side-effect imports):
|
||||
@@ -453,7 +455,9 @@ export default function App() {
|
||||
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
|
||||
{!hideAuthedWidgets && isPhone && <ErrorBoundary boundaryId="widget:push-prompt" fallback={null}><MPushPrompt /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:native-quick-actions" fallback={null}><NativeQuickActionsSync /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
across navigations. react-router runs location updates inside a transition,
|
||||
so a mounted boundary keeps the current page on screen instead of flashing
|
||||
|
||||
@@ -145,7 +145,7 @@ apiClient.interceptors.request.use(
|
||||
)
|
||||
|
||||
export function isAuthPublicPath(pathname: string): boolean {
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password', '/native-handoff']
|
||||
const publicPrefixes = ['/shared/', '/public/']
|
||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAdminUserActions } from './useAdminUserActions';
|
||||
|
||||
// FE-HOOK-ADMUSERACT-001 to FE-HOOK-ADMUSERACT-007
|
||||
// FE-HOOK-ADMUSERACT-001 to FE-HOOK-ADMUSERACT-009
|
||||
|
||||
const api = vi.hoisted(() => ({ rotateJwtSecret: vi.fn(), resetUserPasskeys: vi.fn(), resetUserMfa: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ adminApi: api }));
|
||||
const api = vi.hoisted(() => ({
|
||||
rotateJwtSecret: vi.fn(),
|
||||
resetUserPasskeys: vi.fn(),
|
||||
resetUserMfa: vi.fn(),
|
||||
}));
|
||||
const http = vi.hoisted(() => ({ delete: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ adminApi: api, apiClient: http }));
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key);
|
||||
|
||||
@@ -28,6 +33,7 @@ beforeEach(() => {
|
||||
api.rotateJwtSecret.mockReset().mockResolvedValue({});
|
||||
api.resetUserPasskeys.mockReset().mockResolvedValue({ deleted: 2 });
|
||||
api.resetUserMfa.mockReset().mockResolvedValue({ success: true });
|
||||
http.delete.mockReset().mockResolvedValue({ data: { success: true, unlinked: true } });
|
||||
});
|
||||
|
||||
describe('useAdminUserActions', () => {
|
||||
@@ -101,4 +107,26 @@ describe('useAdminUserActions', () => {
|
||||
expect(admin.toast.error).toHaveBeenCalledWith('common.error');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADMUSERACT-008: removing the SSO link calls the API, toasts and reports success', async () => {
|
||||
const { admin, actions } = mount();
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await actions.unlinkSso({ id: 11 });
|
||||
});
|
||||
expect(http.delete).toHaveBeenCalledWith('/admin/users/11/sso');
|
||||
expect(admin.toast.success).toHaveBeenCalledWith('admin.sso.unlinkDone');
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADMUSERACT-009: a failed SSO unlink toasts and reports failure', async () => {
|
||||
http.delete.mockRejectedValue(new Error('x'));
|
||||
const { admin, actions } = mount();
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await actions.unlinkSso({ id: 11 });
|
||||
});
|
||||
expect(admin.toast.error).toHaveBeenCalledWith('common.error');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { adminApi } from '../../api/client';
|
||||
import { adminApi, apiClient } from '../../api/client';
|
||||
import type { TranslationFn } from '../../types';
|
||||
|
||||
interface UserActionsHost {
|
||||
@@ -12,8 +12,8 @@ interface UserActionsHost {
|
||||
/**
|
||||
* The irreversible user actions behind the admin dialogs and their phone sheets:
|
||||
* rotating the JWT secret, which signs everyone out including the admin, removing a
|
||||
* user's passkeys and switching off a user's two-factor login. Each shell keeps its own
|
||||
* confirm step and busy state around them.
|
||||
* user's passkeys, switching off a user's two-factor login and removing a user's SSO
|
||||
* link. Each shell keeps its own confirm step and busy state around them.
|
||||
*/
|
||||
export function useAdminUserActions(admin: UserActionsHost, t: TranslationFn) {
|
||||
const { toast, setRotatingJwt, setShowRotateJwtModal, logout, navigate } = admin;
|
||||
@@ -55,5 +55,17 @@ export function useAdminUserActions(admin: UserActionsHost, t: TranslationFn) {
|
||||
}
|
||||
};
|
||||
|
||||
return { rotateJwt, resetPasskeys, resetMfa };
|
||||
/** Forgets the user's SSO identity so the next SSO sign-in links again (#3065); true when it worked. */
|
||||
const unlinkSso = async (user: { id: number }): Promise<boolean> => {
|
||||
try {
|
||||
await apiClient.delete(`/admin/users/${user.id}/sso`);
|
||||
toast.success(t('admin.sso.unlinkDone'));
|
||||
return true;
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return { rotateJwt, resetPasskeys, resetMfa, unlinkSso };
|
||||
}
|
||||
|
||||
@@ -77,10 +77,24 @@ describe('CollectionsWidget', () => {
|
||||
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-008: nothing renders while the fetch is still in flight', async () => {
|
||||
render(<CollectionsWidget onOpen={() => {}} />);
|
||||
it('FE-COMP-COLWIDGET-008: only a placeholder shows while the fetch is still in flight', async () => {
|
||||
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
expect(screen.queryByText('No saved places yet')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('No saved places yet')).toBeInTheDocument());
|
||||
// Nothing remembered yet: the empty hint's shape, hidden from assistive tech, holds the place.
|
||||
expect(container.querySelector('.col-empty.trek-skeleton')).toHaveAttribute('aria-hidden', 'true');
|
||||
await waitFor(() => expect(container.querySelector('.trek-skeleton')).toBeNull());
|
||||
expect(screen.getByText('No saved places yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-009: #3009 holds the badges it showed last time while the lists load, then remembers the new count', async () => {
|
||||
localStorage.setItem('trek_dash_collections_shown', '3');
|
||||
collections([1, 2].map(id => list({ id, name: `List ${id}` })));
|
||||
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(3);
|
||||
expect(await screen.findByText('List 1')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(0);
|
||||
await waitFor(() => expect(localStorage.getItem('trek_dash_collections_shown')).toBe('2'));
|
||||
localStorage.clear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Bookmark, ArrowRight, MapPin } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { entityGradient } from '../../utils/gradients'
|
||||
import { useCollectionLists } from './useCollectionLists'
|
||||
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory'
|
||||
|
||||
/**
|
||||
* Dashboard sidebar widget — a glassy `.tool` card that surfaces the user's
|
||||
@@ -16,6 +17,10 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { lists, loading } = useCollectionLists()
|
||||
const [expected] = useState(readShownCollections)
|
||||
useEffect(() => {
|
||||
if (!loading) rememberShownCollections(Math.min(lists.length, 6))
|
||||
}, [loading, lists.length])
|
||||
|
||||
return (
|
||||
<div className="tool">
|
||||
@@ -25,7 +30,15 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{loading ? null : lists.length === 0 ? (
|
||||
{loading ? (
|
||||
expected ? (
|
||||
<div className="col-badges" aria-hidden>
|
||||
{Array.from({ length: expected }, (_, i) => <div key={i} className="col-badge col-badge-pending trek-skeleton" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-empty trek-skeleton" aria-hidden>{t('collections.widget.empty')}</div>
|
||||
)
|
||||
) : lists.length === 0 ? (
|
||||
<div className="col-empty">{t('collections.widget.empty')}</div>
|
||||
) : (
|
||||
<div className="col-badges">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// FE-COMP-COLMEMORY-001 to -002: how many lists the collections widget showed last time.
|
||||
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory';
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe('collectionsWidgetMemory', () => {
|
||||
it('FE-COMP-COLMEMORY-001: remembers the count, kept within the six badges the widget shows', () => {
|
||||
expect(readShownCollections()).toBeNull();
|
||||
rememberShownCollections(4);
|
||||
expect(readShownCollections()).toBe(4);
|
||||
rememberShownCollections(9);
|
||||
expect(readShownCollections()).toBe(6);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMEMORY-002: ignores a value that is not a count', () => {
|
||||
localStorage.setItem('trek_dash_collections_shown', 'many');
|
||||
expect(readShownCollections()).toBeNull();
|
||||
localStorage.setItem('trek_dash_collections_shown', '42');
|
||||
expect(readShownCollections()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
const KEY = 'trek_dash_collections_shown';
|
||||
|
||||
/**
|
||||
* How many lists the dashboard's collections widget showed last time. While the
|
||||
* lists load, the widget stands in with that many placeholder badges, so it
|
||||
* opens at the height it will have and the widgets under it stay put (#3009).
|
||||
* Null when nothing is remembered yet, or storage is unavailable.
|
||||
*/
|
||||
export function readShownCollections(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw == null) return null;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(n) && n >= 0 && n <= 6 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberShownCollections(count: number): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, String(Math.max(0, Math.min(6, count))));
|
||||
} catch {
|
||||
/* storage blocked: the widget just opens at its empty height next time */
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-FXHOOK-001 to -008: the currency converter both dashboards share.
|
||||
// FE-COMP-FXHOOK-001 to -010: the currency converter both dashboards share.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -13,7 +13,7 @@ const RATES = [
|
||||
const updateSetting = vi.fn<(key: string, value: unknown) => Promise<void>>();
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function seedSettings(over: Record<string, unknown> = {}, isLoaded = false) {
|
||||
function seedSettings(over: Record<string, unknown> = {}, isLoaded = true) {
|
||||
useSettingsStore.setState((s) => ({
|
||||
isLoaded,
|
||||
updateSetting: updateSetting as unknown as typeof s.updateSetting,
|
||||
@@ -133,4 +133,33 @@ describe('useCurrencyConverter', () => {
|
||||
});
|
||||
expect(result.current.rate).toBe(1.3);
|
||||
});
|
||||
|
||||
it('FE-COMP-FXHOOK-009: #3009 waits for the saved pair before it fetches or shows a pair', async () => {
|
||||
seedSettings({}, false);
|
||||
const { result } = renderHook(() => useCurrencyConverter());
|
||||
expect(result.current.pairPending).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
|
||||
expect(result.current.pairPending).toBe(false);
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('base=JPY');
|
||||
});
|
||||
|
||||
it('FE-COMP-FXHOOK-010: #3009 never prices a new pair with the previous base and says it is still loading', async () => {
|
||||
fetchMock = vi.fn((url: string) =>
|
||||
respond(url.endsWith('base=JPY') ? [{ quote: 'EUR', rate: 0.0056 }] : [{ quote: 'USD', rate: 1.1 }])
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { result } = renderHook(() => useCurrencyConverter());
|
||||
await waitFor(() => expect(result.current.rate).toBe(1.1));
|
||||
|
||||
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
|
||||
// EUR's map holds EUR at 1, which used to show as "1 JPY = 1.0000 EUR".
|
||||
expect(result.current.rate).toBeNull();
|
||||
expect(result.current.ratePending).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.rate).toBe(0.0056));
|
||||
expect(result.current.ratePending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,10 +23,18 @@ export function useCurrencyConverter() {
|
||||
updateSetting('dashboard_fx_to', v).catch(() => {});
|
||||
};
|
||||
const [amount, setAmount] = useState('100');
|
||||
const [rates, setRates] = useState<Record<string, number> | null>(null);
|
||||
// The rates carry the base they were fetched for: the pair can change before
|
||||
// the new base's rates arrive, and the old map priced the new pair wrongly
|
||||
// (EUR's self-rate of 1 showed as "1 JPY = 1.0000 EUR", #3009).
|
||||
const [rates, setRates] = useState<{ base: string; map: Record<string, number> } | null>(null);
|
||||
// The base whose rates could not be had, so a failure belongs to its pair only.
|
||||
const [failedBase, setFailedBase] = useState<string | null>(null);
|
||||
const inFlight = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchRates = useCallback(() => {
|
||||
// Not before the user's pair is known: the defaults' rates were fetched and
|
||||
// shown first, then replaced by the saved pair (#3009).
|
||||
if (!isLoaded) return;
|
||||
inFlight.current?.abort();
|
||||
const controller = new AbortController();
|
||||
inFlight.current = controller;
|
||||
@@ -37,18 +45,22 @@ export function useCurrencyConverter() {
|
||||
if (signal.aborted) return;
|
||||
if (!Array.isArray(d)) {
|
||||
setRates(null);
|
||||
setFailedBase(from);
|
||||
return;
|
||||
}
|
||||
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
|
||||
const map: Record<string, number> = { [from]: 1 };
|
||||
for (const r of d) map[r.quote] = r.rate;
|
||||
setRates(map);
|
||||
setFailedBase(null);
|
||||
setRates({ base: from, map });
|
||||
})
|
||||
.catch(() => {
|
||||
// An abort is not a failure: it means nobody is waiting for the answer.
|
||||
if (!signal.aborted) setRates(null);
|
||||
if (signal.aborted) return;
|
||||
setRates(null);
|
||||
setFailedBase(from);
|
||||
});
|
||||
}, [from]);
|
||||
}, [from, isLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRates();
|
||||
@@ -78,13 +90,32 @@ export function useCurrencyConverter() {
|
||||
});
|
||||
}, [isLoaded, updateSetting]);
|
||||
|
||||
const currencies: string[] = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
|
||||
const rate = rates?.[to] ?? null;
|
||||
const current = rates?.base === from ? rates.map : null;
|
||||
const currencies: string[] = current ? Object.keys(current).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
|
||||
const rate = current?.[to] ?? null;
|
||||
// The pair is not known yet, or its rates are still on their way: neither is
|
||||
// "rate unavailable", and neither may show a rate.
|
||||
const pairPending = !isLoaded;
|
||||
const ratePending = isLoaded && !current && failedBase !== from;
|
||||
const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null;
|
||||
const swap = () => {
|
||||
setFrom(to);
|
||||
setTo(from);
|
||||
};
|
||||
|
||||
return { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates };
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
setFrom,
|
||||
setTo,
|
||||
amount,
|
||||
setAmount,
|
||||
currencies,
|
||||
rate,
|
||||
converted,
|
||||
swap,
|
||||
fetchRates,
|
||||
pairPending,
|
||||
ratePending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JourneyMapPlace } from '@trek/shared';
|
||||
import { useCallback, useState, type ComponentProps, type Ref } from 'react';
|
||||
import JourneyMap, { type JourneyMapHandle } from './JourneyMap';
|
||||
import JourneyMapAuto from './JourneyMapAuto';
|
||||
@@ -27,6 +28,13 @@ interface Props extends MapProps {
|
||||
/** Leads from a picked place or stay to its entry. */
|
||||
onOpenEntry?: (entryId: string) => void;
|
||||
entryTitleOf?: (entryId: number) => string | null;
|
||||
/** Starts an entry about a picked planned place nothing was written about; absent for a reader who cannot. */
|
||||
onWriteAboutPlace?: (place: JourneyMapPlace) => void;
|
||||
}
|
||||
|
||||
/** A card action, only where the screen offers one. */
|
||||
function offeredIf<T>(offered: unknown, action: T): T | undefined {
|
||||
return offered ? action : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +52,7 @@ export default function JourneyLayeredMap({
|
||||
controlsTop = 12,
|
||||
onOpenEntry,
|
||||
entryTitleOf,
|
||||
onWriteAboutPlace,
|
||||
photos,
|
||||
tracks,
|
||||
height = 220,
|
||||
@@ -55,6 +64,13 @@ export default function JourneyLayeredMap({
|
||||
setPicked({ marks, key: marks.map((m) => m.key).join(',') });
|
||||
}, []);
|
||||
const close = useCallback(() => setPicked(null), []);
|
||||
const writeAbout = useCallback(
|
||||
(place: JourneyMapPlace) => {
|
||||
setPicked(null);
|
||||
onWriteAboutPlace?.(place);
|
||||
},
|
||||
[onWriteAboutPlace]
|
||||
);
|
||||
const openEntry = useCallback(
|
||||
(entryId: string) => {
|
||||
setPicked(null);
|
||||
@@ -99,6 +115,7 @@ export default function JourneyLayeredMap({
|
||||
marks={card.marks}
|
||||
onClose={close}
|
||||
onOpenEntry={onOpenEntry ? openEntry : undefined}
|
||||
onWriteAbout={offeredIf(onWriteAboutPlace, writeAbout)}
|
||||
entryTitleOf={entryTitleOf}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -86,4 +86,23 @@ describe('JourneyMapPickCard', () => {
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-MAPPICK-004: a place nothing was written about offers a new entry to whoever can write', () => {
|
||||
const onWriteAbout = vi.fn();
|
||||
const { unmount } = render(<JourneyMapPickCard marks={[plan()]} onClose={vi.fn()} onWriteAbout={onWriteAbout} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New Entry' }));
|
||||
expect(onWriteAbout).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4, name: 'Piazza San Marco' }));
|
||||
unmount();
|
||||
|
||||
// A reader who cannot write, or a place already written about, gets no such button.
|
||||
render(
|
||||
<JourneyMapPickCard
|
||||
marks={[plan({ entry_id: 7 })]}
|
||||
onClose={vi.fn()}
|
||||
onOpenEntry={vi.fn()}
|
||||
onWriteAbout={onWriteAbout}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'New Entry' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
|
||||
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, X } from 'lucide-react';
|
||||
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, PenLine, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../Planner/planParts';
|
||||
@@ -14,6 +14,8 @@ interface Props {
|
||||
onOpenEntry?: (entryId: string) => void;
|
||||
/** The title of an entry the reader can see, for the stay that belongs to it. */
|
||||
entryTitleOf?: (entryId: number) => string | null;
|
||||
/** Starts an entry about a planned place nothing was written about; absent where the reader cannot write. */
|
||||
onWriteAbout?: (place: JourneyMapPlace) => void;
|
||||
}
|
||||
|
||||
function dayLabel(date: string, locale: string): string {
|
||||
@@ -41,7 +43,15 @@ function OpenEntry({ entryId, onOpenEntry }: { entryId: number; onOpenEntry?: (e
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEntry?: (entryId: string) => void }) {
|
||||
function PlaceDetail({
|
||||
place,
|
||||
onOpenEntry,
|
||||
onWriteAbout,
|
||||
}: {
|
||||
place: JourneyMapPlace;
|
||||
onOpenEntry?: (entryId: string) => void;
|
||||
onWriteAbout?: (place: JourneyMapPlace) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -56,9 +66,22 @@ function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEnt
|
||||
{place.entry_id !== null ? (
|
||||
<OpenEntry entryId={place.entry_id} onOpenEntry={onOpenEntry} />
|
||||
) : (
|
||||
<p className="text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('journey.map.pick.notWritten')}
|
||||
</p>
|
||||
<>
|
||||
<p className="text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('journey.map.pick.notWritten')}
|
||||
</p>
|
||||
{onWriteAbout && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWriteAbout(place)}
|
||||
className="inline-flex items-center gap-1 self-start rounded-lg bg-accent px-2.5 py-1.5 font-semibold text-accent-text hover:opacity-90"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<PenLine size={12} strokeWidth={2.2} aria-hidden />
|
||||
{t('journey.detail.newEntry')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -106,7 +129,7 @@ function markName(mark: JourneyMapMark): string {
|
||||
* one spot come as a list first, each with what kind it is, so overlapping
|
||||
* hits stay reachable one by one.
|
||||
*/
|
||||
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf }: Props) {
|
||||
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf, onWriteAbout }: Props) {
|
||||
const { t, locale } = useTranslation();
|
||||
const [picked, setPicked] = useState<JourneyMapMark | null>(marks.length === 1 ? marks[0] : null);
|
||||
|
||||
@@ -150,7 +173,9 @@ export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryT
|
||||
<X size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
{picked?.kind === 'plan' && <PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} />}
|
||||
{picked?.kind === 'plan' && (
|
||||
<PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} onWriteAbout={onWriteAbout} />
|
||||
)}
|
||||
{picked?.kind === 'stay' && (
|
||||
<StayDetail stay={picked.stay} onOpenEntry={onOpenEntry} entryTitleOf={entryTitleOf} />
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import JourneyDayScrubber from './JourneyDayScrubber'
|
||||
import { dayColorOf } from './journeyCard'
|
||||
import { useJourneyCarouselSync } from './useJourneyCarouselSync'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import type { JourneyMapPlace, JourneyTrack } from '@trek/shared'
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
@@ -40,6 +40,8 @@ interface Props {
|
||||
/** The map's layers (JRN-04); a picked place or stay leads to its entry's card. */
|
||||
layers?: JourneyMapLayersView | null
|
||||
entryTitleOf?: (entryId: number) => string | null
|
||||
/** Starts an entry about a picked planned place nothing was written about. */
|
||||
onWriteAboutPlace?: (place: JourneyMapPlace) => void
|
||||
/** Geotagged photos on the map, a tap opening them. */
|
||||
photos?: MapPhoto[]
|
||||
onPhotoClick?: (photoIds: string[]) => void
|
||||
@@ -64,6 +66,7 @@ export default function MobileMapTimeline({
|
||||
initialEntryId,
|
||||
layers,
|
||||
entryTitleOf,
|
||||
onWriteAboutPlace,
|
||||
photos,
|
||||
onPhotoClick,
|
||||
controlsTop,
|
||||
@@ -118,6 +121,7 @@ export default function MobileMapTimeline({
|
||||
hideLayerControl
|
||||
controlsTop={controlsTop}
|
||||
onOpenEntry={handleMarkerClick}
|
||||
onWriteAboutPlace={onWriteAboutPlace}
|
||||
entryTitleOf={entryTitleOf}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
@@ -160,6 +164,7 @@ export default function MobileMapTimeline({
|
||||
hideLayerControl
|
||||
controlsTop={controlsTop}
|
||||
onOpenEntry={handleMarkerClick}
|
||||
onWriteAboutPlace={onWriteAboutPlace}
|
||||
entryTitleOf={entryTitleOf}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* NativeQuickActionsSync: keeps the app icon's quick actions (native/quickActions.ts)
|
||||
* in step with the trips on this device and the app's language. Runs at start,
|
||||
* on a change of language or account, and whenever the app comes back to the
|
||||
* foreground. Signed out, the icon offers nothing that would open a trip.
|
||||
* Renders nothing; outside an iOS binary with the feature it does nothing.
|
||||
*/
|
||||
import { App } from '@capacitor/app';
|
||||
import { useEffect } from 'react';
|
||||
import { offlineDb } from '../../db/offlineDb';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { publishQuickActions, quickActionsActive, quickActionsFor } from '../../native/quickActions';
|
||||
import { pickTrip } from '../../native/widgets';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { localDateStr } from '../../vacay/monthGrid';
|
||||
|
||||
export default function NativeQuickActionsSync() {
|
||||
const { t, language } = useTranslation();
|
||||
const { caps } = useNativeCapabilities();
|
||||
const userId = useAuthStore((s) => (s.isAuthenticated ? (s.user?.id ?? null) : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (!quickActionsActive()) return;
|
||||
let cancelled = false;
|
||||
|
||||
const sync = async () => {
|
||||
if (userId === null) {
|
||||
await publishQuickActions([]);
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const trips = await offlineDb.trips.toArray();
|
||||
if (cancelled) return;
|
||||
await publishQuickActions(quickActionsFor(pickTrip(trips, today), today, t));
|
||||
};
|
||||
|
||||
void sync();
|
||||
const listener = App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive) void sync();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void listener.then((handle) => handle.remove());
|
||||
};
|
||||
}, [caps, t, language, userId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -14,11 +14,13 @@ import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { TrekShell } from '../../native/trekShell';
|
||||
import { applyWatchActions, buildWatchSnapshot, publishWatch, takeWatchActions, watchActive } from '../../native/watch';
|
||||
import { onWatchListsChange } from '../../native/watchListChanges';
|
||||
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
|
||||
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
|
||||
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
|
||||
import {
|
||||
buildSnapshot,
|
||||
liveDayDue,
|
||||
pickTrip,
|
||||
publishWidgets,
|
||||
todayOf,
|
||||
@@ -206,9 +208,10 @@ export default function NativeWidgetsSync(): null {
|
||||
});
|
||||
}
|
||||
if (cancelled) return;
|
||||
const live = main.day
|
||||
? tripDayState(main.day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone'))
|
||||
: null;
|
||||
// The Live Activity from three hours before the day's first timed stop, not all day long.
|
||||
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||
const live =
|
||||
main.day && liveDayDue(main.day, minutes) ? tripDayState(main.day, minutes, t('native.widget.dayDone')) : null;
|
||||
await publishWidgets(main.snapshot, live, choices);
|
||||
await publishToWatch(hero, main, today);
|
||||
};
|
||||
@@ -229,8 +232,11 @@ export default function NativeWidgetsSync(): null {
|
||||
if (isActive) void sync().then(drainWatch);
|
||||
});
|
||||
const watchListener = watchActive() ? TrekShell.addListener('watchAction', () => void drainWatch()) : null;
|
||||
// A tick here, or one another member made, reaches the watch at once rather than on the next refresh.
|
||||
const stopListWatch = watchActive() ? onWatchListsChange(() => void sync()) : null;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopListWatch?.();
|
||||
window.clearInterval(timer);
|
||||
void listener.then((handle) => handle.remove());
|
||||
void watchListener?.then((handle) => handle.remove());
|
||||
|
||||
@@ -122,8 +122,8 @@ describe('DawarichTrailLayer', () => {
|
||||
|
||||
const [casing, line] = lines()
|
||||
expect(casing.getAttribute('data-pane')).toBe('trek-track-casing')
|
||||
expect(casing.getAttribute('data-weight')).toBe('6')
|
||||
expect(casing.getAttribute('data-opacity')).toBe('0.55')
|
||||
expect(casing.getAttribute('data-weight')).toBe('7')
|
||||
expect(casing.getAttribute('data-opacity')).toBe('0.9')
|
||||
|
||||
// Only the casing takes the pane; the coloured line is left in Leaflet's
|
||||
// default overlay pane. Pinned as the fact it is rather than as a rule:
|
||||
@@ -131,8 +131,9 @@ describe('DawarichTrailLayer', () => {
|
||||
// it, so the recorded line currently draws over the plan while the GL twin
|
||||
// puts both of its layers under it.
|
||||
expect(line.getAttribute('data-pane')).toBe('')
|
||||
expect(line.getAttribute('data-weight')).toBe('3')
|
||||
expect(line.getAttribute('data-dash')).toBe('6 5')
|
||||
// Full colour, wide and long-dashed, so it reads on a busy street map.
|
||||
expect(line.getAttribute('data-weight')).toBe('4')
|
||||
expect(line.getAttribute('data-dash')).toBe('10 5')
|
||||
expect(line.getAttribute('data-points')).toBe(JSON.stringify([[48.2, 16.37], [48.21, 16.38]]))
|
||||
|
||||
expect(lines().every(el => el.getAttribute('data-interactive') === 'false')).toBe(true)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { frameLine } from '../../utils/lngFrame'
|
||||
import type { DawarichTrack } from '@trek/shared'
|
||||
import {
|
||||
DAWARICH_TRAIL_CASING,
|
||||
DAWARICH_TRAIL_STROKE,
|
||||
trailSegments,
|
||||
type DawarichTrailSegment,
|
||||
} from './dawarichTrail'
|
||||
@@ -55,8 +56,8 @@ export default function DawarichTrailLayer({
|
||||
// theme-lint-disable — map paint
|
||||
pathOptions={{
|
||||
color: DAWARICH_TRAIL_CASING,
|
||||
weight: 6,
|
||||
opacity: 0.55,
|
||||
weight: DAWARICH_TRAIL_STROKE.casingWidth,
|
||||
opacity: DAWARICH_TRAIL_STROKE.casingOpacity,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
@@ -72,9 +73,9 @@ export default function DawarichTrailLayer({
|
||||
positions={segment.points}
|
||||
pathOptions={{
|
||||
color: segment.color,
|
||||
weight: 3,
|
||||
opacity: 0.85,
|
||||
dashArray: '6 5',
|
||||
weight: DAWARICH_TRAIL_STROKE.width,
|
||||
opacity: DAWARICH_TRAIL_STROKE.opacity,
|
||||
dashArray: DAWARICH_TRAIL_STROKE.dashLeaflet,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
|
||||
@@ -312,3 +312,50 @@ describe('TourMapLayerSwitcher piste switch (SKI-01)', () => {
|
||||
expect(screen.getByRole('status')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TourMapLayerSwitcher fitting its map', () => {
|
||||
/** A parent of `room` px, and the pill reporting `withWords` px while its words show. */
|
||||
function renderIn(room: { width: number }, withWords: number) {
|
||||
const host = document.createElement('div')
|
||||
Object.defineProperty(host, 'clientWidth', { get: () => room.width })
|
||||
document.body.appendChild(host)
|
||||
const view = render(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />, { container: host })
|
||||
const pill = () => screen.getByRole('group')
|
||||
Object.defineProperty(pill(), 'scrollWidth', {
|
||||
get: () => (pill().dataset.layout === 'labels' ? withWords : 200),
|
||||
})
|
||||
return { ...view, pill }
|
||||
}
|
||||
|
||||
it('FE-COMP-MAPLAYER-025: keeps its words while they fit, goes to its icons on a narrow map, and back once there is room', () => {
|
||||
const room = { width: 900 }
|
||||
const { pill, rerender } = renderIn(room, 600)
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('labels')
|
||||
expect(screen.getByText('Topo')).toBeInTheDocument()
|
||||
|
||||
room.width = 500
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('icons')
|
||||
expect(screen.queryByText('Topo')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Topo' })).toBeInTheDocument()
|
||||
|
||||
room.width = 599
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('icons')
|
||||
room.width = 600
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('labels')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-026: stood on the side it is a column of icons, and a set compact is never second-guessed', () => {
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} vertical />)
|
||||
expect(screen.getByRole('group').dataset.layout).toBe('vertical')
|
||||
expect(screen.getByRole('group').style.flexDirection).toBe('column')
|
||||
expect(screen.queryByText('Topo')).toBeNull()
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} compact={false} />)
|
||||
expect(screen.getByRole('group').dataset.layout).toBe('labels')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
||||
import { PisteKeyButton } from '../Tours/winter/PisteKey'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { ComponentType, CSSProperties } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||
@@ -11,6 +11,7 @@ import { Tooltip } from '../shared/Tooltip'
|
||||
import { FROSTED_SHELL, segmentStyle } from './mapControlShadow'
|
||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
|
||||
import { useLabelFit } from './useLabelFit'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
export type TourBaseLayer = BaseLayer | TourRasterBase
|
||||
@@ -31,6 +32,7 @@ export interface TourLayerOffer {
|
||||
|
||||
/** Distance from the map's left edge (plus any side panel) to the switcher. */
|
||||
export const MAP_LAYER_SWITCHER_INSET = 20
|
||||
|
||||
/**
|
||||
* Outer size of the round shell: a 34px button in 4px of padding, the same shell as
|
||||
* MapCompassPill. The markup below keeps its literals, and FE-COMP-MAPLAYER-006 holds
|
||||
@@ -257,11 +259,31 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
||||
)
|
||||
}
|
||||
|
||||
/** Words, icons only, or a column on the map's side: set by the caller, or fitted to the room. */
|
||||
function useSwitcherLayout(compactProp: boolean | undefined, vertical: boolean) {
|
||||
const { attach, iconsOnly } = useLabelFit(compactProp === undefined && !vertical)
|
||||
const compact = vertical || (compactProp ?? iconsOnly)
|
||||
const layout = vertical ? 'vertical' : compact ? 'icons' : 'labels'
|
||||
return { attachFit: attach, compact, layout }
|
||||
}
|
||||
|
||||
function shellStyle(vertical: boolean): CSSProperties {
|
||||
return vertical ? { ...FROSTED_SHELL, gap: 2, flexDirection: 'column', borderRadius: 22 } : { ...FROSTED_SHELL, gap: 2 }
|
||||
}
|
||||
|
||||
function Divider({ vertical }: { vertical: boolean }) {
|
||||
return <span aria-hidden className={vertical ? 'my-0.5 h-px w-5 bg-edge' : 'mx-0.5 h-5 w-px bg-edge'} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-view Tours control. It never reads or writes the global map setting.
|
||||
* `compact` shows the base layers and the via ferrata switch by their icons
|
||||
* alone, each named by its tooltip, for a bottom row that has to share its
|
||||
* width (the 3D map's, beside the flyover dock).
|
||||
* width (the 3D map's, beside the flyover dock). Left out, the control goes to
|
||||
* its icons by itself once its words no longer fit the room its parent gives
|
||||
* it (a narrow map, such as the iPad's beside the tour pane).
|
||||
* `vertical` stands it on the map's side, icons only (the 3D map, where its
|
||||
* bottom row has no room left for it).
|
||||
*/
|
||||
export function TourMapLayerSwitcher({
|
||||
active,
|
||||
@@ -271,7 +293,8 @@ export function TourMapLayerSwitcher({
|
||||
ferrataLayer,
|
||||
placeTool,
|
||||
layers,
|
||||
compact = false,
|
||||
compact: compactProp,
|
||||
vertical = false,
|
||||
}: {
|
||||
active: TourBaseLayer
|
||||
onChange: (layer: TourBaseLayer) => void
|
||||
@@ -285,15 +308,23 @@ export function TourMapLayerSwitcher({
|
||||
/** Offers the "set points" switch, while a tour is being edited. */
|
||||
placeTool?: MapPlaceTool
|
||||
compact?: boolean
|
||||
vertical?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { attachFit, compact, layout } = useSwitcherLayout(compactProp, vertical)
|
||||
const bases = layers?.bases
|
||||
const overlays = layers?.overlays
|
||||
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
|
||||
const health = layers?.baseHealth
|
||||
|
||||
return (
|
||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
||||
<div
|
||||
ref={attachFit}
|
||||
style={shellStyle(vertical)}
|
||||
role="group"
|
||||
aria-label={t('settings.mapStyle')}
|
||||
data-layout={layout}
|
||||
>
|
||||
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
||||
const selected = active === id
|
||||
const label = t(labelKey)
|
||||
@@ -321,7 +352,7 @@ export function TourMapLayerSwitcher({
|
||||
{health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')}
|
||||
</span>
|
||||
)}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <Divider vertical={vertical} />}
|
||||
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
||||
{overlays?.map(overlay => <OverlayToggle key={overlay.id} overlay={overlay} compact={compact} />)}
|
||||
|
||||
@@ -73,7 +73,8 @@ import VectorBasemap from './VectorBasemap'
|
||||
import { nativeRasterTemplate } from '../../native/tileCache'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { switcherRowStyle } from './switcherRow'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import type { ViewportPadding } from '../../utils/mapViewport'
|
||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
|
||||
@@ -1126,7 +1127,7 @@ export const MapView = memo(function MapView({
|
||||
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
|
||||
smaller gap its shadow ran into the sidebar edge and the two read as one
|
||||
surface. */}
|
||||
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ ...switcherRowStyle(leftWidth, rightWidth), bottom: switcherBottom }}>
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user