Compare commits

...

122 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

iOS no longer lifts a page preview on a long press on a link.
2026-11-09 08:54:51 +01:00
Maurice d9baaa8957 fix(native): a long press in the app selects no text and opens no callout
In the iOS and Android app a long press on a label, card or image started a text
selection, the magnifier or a "save image" menu, the clearest sign of a web page
in a frame. startNativeApp marks <html> with data-native-app, and index.css then
turns selection and the callout off for the UI. Inputs, textareas, selects and
editors stay selectable so typing, pasting and the caret work; text worth copying
opts in with .trek-selectable. A browser keeps its default.
2026-11-09 08:32:24 +01:00
Maurice fede671f3b fix(managed): bootstrap hands the managed routes to the boot gate, so the shared kernel imports no domain 2026-11-09 00:09:27 +00:00
Maurice b3113e66b9 feat(native): buying TREK Cloud in the app asks for the password of the new TREK and opens it signed in
The claim screen takes the password (TREK's policy, checked before anything is
bought) and sends it with the purchase; the control plane answers with a
one-time welcome code, which the app hands to setServer. iOS and Android open
the instance's welcome page with it once after the first page, which signs the
buyer in. Without the code (a restore, an older control plane) the app opens
the instance as before.
2026-11-08 23:43:15 +00:00
Maurice 286b0b09ec feat(managed): the managed attachment point declares the routes it serves without a session, and the boot gate allows exactly those
Empty in this repository, as the module itself. An install that replaces the
module adds its anonymous routes in the same file, and the gate still fails on a
route missing there or an entry that went stale.
2026-11-08 23:33:52 +00:00
Maurice c0b39abe18 style(i18n): the German native strings formatted as Prettier asks 2026-11-08 23:19:39 +00:00
Maurice 6e3a6386b8 fix(i18n): German setup screens speak of "dein TREK", as everywhere else, and the proxy note reads naturally 2026-11-08 22:24:29 +01:00
Maurice 5e7d778740 fix(assistant): the step after the ask for the answer may only write text, so Claude Sonnet 5.5 can no longer offer its follow-ups a second time instead (#3016) 2026-11-08 21:16:09 +01:00
Maurice 3be87343b8 fix: the gate texts formatted as the locales are, gate detection as a table of rules, and no non-null assertions in its tests 2026-11-08 20:58:49 +01:00
Maurice 517efabbca feat(native): the address screen recognises a sign-in proxy in front of TREK and signs in to it inside the app, so its session works as in a browser 2026-11-08 20:44:26 +01:00
Maurice 1866dca818 test(dashboard): the pair-change test stubs fetch with its own mock instead of a promise-returning mockImplementation 2026-11-08 20:10:22 +01:00
Maurice 1400b5d308 fix: the iOS tile trim handles its own failure instead of an empty catch, and the weight test's slow row saves without an async prop 2026-11-08 19:57:43 +01:00
Maurice b529cf4e57 fix: the checks the layout and weight fixes broke: a quote in a test name, state set inside effects, the misused-promise baseline, and tests that render the converter before settings load 2026-11-08 19:44:50 +01:00
Maurice 6cc3d235b1 fix(offline): on an iPhone or iPad the tile prefetch keeps to 2,048 tiles and a fuller cache is trimmed once a day, so the home screen app no longer starts on a black screen (#3008) 2026-11-08 19:33:13 +01:00
Maurice a5b1156612 fix(dashboard): the hero, the atlas cards and the collections widget hold their size while they load, and the currency widget shows only the saved pair and its own rates (#3009) 2026-11-08 19:26:18 +01:00
Maurice c6ffd48e26 fix(packing): the weight field keeps what is typed and saves it once, on blur or Enter, instead of a fragment per keystroke on a slow connection (#3005) 2026-11-08 19:19:04 +01:00
Maurice afb6eae4c8 fix(native): the handoff's passkey sign-in lives in native/browserSignIn, so the page hook reaches no API on its own 2026-11-08 18:33:00 +01:00
Maurice cd1f39377d test(native): the handoff page's fixture knows the passkey step, and the hook test keeps the real API client around its passkey mocks 2026-11-08 18:10:55 +01:00
Maurice 3a99e8576d fix(android): the sign-in browser opens from the UI thread, falls back to the plain browser where no Custom Tab comes up, and says so when none opens 2026-11-08 17:54:43 +01:00
Maurice 36c8c5bc48 fix(native): a passkey sign-in from the app asks for the passkey straight away in the browser and returns to the app, instead of showing the whole login a second time 2026-11-08 17:50:50 +01:00
Maurice 4ba7db90e6 fix(native): SSO in the system browser hands back to the app even when the provider finishes in another tab, and the handoff button's label shows 2026-11-08 17:43:28 +01:00
Maurice ab6cd7009e fix(login): the language picker's safe-area offset as classes, within the file's size baseline 2026-11-08 17:14:25 +01:00
Maurice b16ac683cf fix(native): Android's back gesture steps back through the first screens, the login's language picker clears the status bar, and a slow trip open shows its splash sooner 2026-11-08 16:51:28 +01:00
github-actions[bot] 95d62b7a02 chore: bump version to 5.1.1 [skip ci] 2026-11-08 14:52:31 +00:00
Maurice e444e855c0 fix(ci): the release counts only plain vX.Y.Z tags, not the apps' TestFlight tags [skip ci] 2026-11-08 15:48:30 +01:00
Maurice 4ab42c06c9 fix(native): spell out the welcome screen's translucent mixes, which older Android web views drew opaque as halos and rings 2026-11-08 15:07:41 +01:00
Maurice 9f6ab11839 fix(native): no logo shadow on the Android welcome screen, where the web view drew it as a dark halo 2026-11-08 14:36:12 +01:00
Maurice 5f711d5374 fix: format the floating panel stylesheet and keep the locate-button room private 2026-11-08 13:33:01 +01:00
Maurice 4d550112a1 fix(mobile): Studio's inspector stays docked on an iPad in landscape, the linked journal's buttons sit beside its switch, and the iOS app draws Leaflet's basemap as raster tiles 2026-11-08 13:12:53 +01:00
Maurice 26dc88181d fix: the file size, theme and reading direction ratchets the last push tipped; widget previews and watch screens run by hand only 2026-11-08 13:09:53 +01:00
Maurice 3d4f8bfe12 fix(docker): take the base image's pending Debian security fixes, so the image scan passes 2026-11-08 12:48:19 +01:00
Maurice 5fb1838c7a fix(mobile): switching collection lists changes the content in place instead of sliding a new page in 2026-11-08 12:41:17 +01:00
Maurice 38e5d76ad2 fix(mobile): Studio's floating panels are opaque over the page, the journal header lays out in one row on a tablet, and the journal a trip links opens Studio too 2026-11-08 12:38:39 +01:00
Maurice a9af19f02a fix(mobile): the journal as a trip tab keeps its switch and menu in the shell's top row, like the tour tab 2026-11-08 12:34:41 +01:00
Maurice 305b00ae01 fix(mobile): on a narrow 3D map the flyover controls and grade legend stand as a column of icons on the side, the layer pill keeps the bottom row; and the lint ratchets the last push tipped 2026-11-08 12:30:12 +01:00
Maurice ed6724035e fix(offline): a Cache Storage open that never settles is given up on, so preparing for offline and the offline map no longer wait on it forever 2026-11-08 12:26:07 +01:00
Maurice eea83bb1c9 fix: GL maps read the stored copy again while offline, and a switch that is on keeps a visible knob in the dark scheme 2026-11-08 12:21:52 +01:00
Maurice 06c03cbded fix(mobile): the 3D grade legend goes compact on the side, and a field under the keyboard gets room to scroll above it 2026-11-08 12:15:34 +01:00
Maurice b73f749880 fix(mobile): no how-to line on the 3D map on a tablet 2026-11-08 12:13:20 +01:00
Maurice 53c18b861a fix(native): GL maps load straight from their servers in the app, past the schemes they drew nothing through on iPadOS 26 2026-11-08 12:11:23 +01:00
Maurice 9b1bf89382 fix(native): a field low on a screen scrolls above the keyboard when it comes up 2026-11-08 12:07:57 +01:00
Maurice c7f144d3ff fix: the map falls back to raster where the vector style cannot load, the tour layer pill fits narrow maps and stands on the side in 3D, the dashboard's widget column scrolls on its own, and the CI checks the watch sync and journey write-up broke 2026-11-08 12:05:59 +01:00
Maurice cb41f08199 fix(mobile): the tour tab's rail heads are rounded bands like the cards under them 2026-11-08 11:50:25 +01:00
Maurice eafb30555c fix(mobile): the iPad's tour tab opens saved tours, keeps its switch in the top row, and a tap shows no tooltip 2026-11-08 11:43:45 +01:00
Maurice 4f1f48eec5 fix: watch sync reads the roster through trip membership, image viewer on theme tokens, retry flaky release downloads in CI 2026-11-08 11:30:45 +01:00
Maurice 42341c2c76 fix(mobile): iPad feedback: columns start level, map controls sit low, no day-rail chevrons, no bottom-bar settings on a tablet, and a planned place on the journey map can be written up 2026-11-08 11:30:45 +01:00
Maurice 6a60fc183a feat(native): the app asks for notifications by itself, once
A moment after signing in, the app asks whether to turn notifications on, once per device and only where the server offers push and the device is not signed up yet. Turning them on goes the way of the settings card, which brings up the system's question. iOS asked only when someone found that card.
2026-11-08 11:05:07 +01:00
Maurice 236b4c34ac fix(mobile): the chat's composer stays above the keyboard, and its pictures open large
In the app the keyboard resizes only the body, which leaves the trip shell's fixed layers as they were, so the composer sat under the keys. The keyboard inset now also follows the height the app reports, and the chat lifts its composer by it. A picture in the chat opens full screen on a tap and zooms on a second; it sits below its bubble, keeps the long press for the message's actions, and a picture sent alone has no empty bubble beside it.
2026-11-08 11:05:07 +01:00
Maurice c6cb3dc64a feat(native): a silent push keeps every watch on a trip current
A change to a trip's packing lists or to-dos (any writer: REST, MCP, plugin RPC) is noted from the realtime broadcast and, a couple of seconds later, each member with the iPhone app gets a silent push through the app relay, marked as a background push. The push carries no content: the woken app reads both lists of the trip its watch shows from the server with its own session and hands them to the watch. The app gains the remote-notification background mode for it.
2026-11-08 11:05:06 +01:00
Maurice 3fc7487b58 fix(ci): the lint ratchet, and SwiftPM's half-downloaded artifacts on the iOS runners
The import sheet no longer sets state from its preset effect and is below the complexity limit; the trip shell's splash tests await their timers. The iOS and watch builds resolve their Swift packages first on a clean artifact cache, up to three times: a failed download from GitHub left a partial Capacitor xcframework behind and every later attempt stopped on it.
2026-11-08 11:05:06 +01:00
Maurice ac055d531f fix: the checks the tour tab, the shared links and the watch sync broke on dev
The tour tab keeps desktopOnly as a plain property, so the tab list's type still has it. The wiring test follows the tab's new gate. The import sheet keeps the choice and the save with the item they were made for instead of resetting them in an effect, takes the share sheet's trip through an effect event instead of a ref read while rendering, and is split into its parts. The tours tab sets its pane while rendering, as React has it for state that follows a prop. The sheet's tests look for the texts it shows. The Android link test reads the host with java.net.URI, which a JVM unit test can run. Two locale files take Prettier's quotes.
2026-11-08 10:37:41 +01:00
Maurice 0ef3cbf520 feat(mobile): a trip opens without its loading screen on the phone and the tablet
The touch shell waited for the desktop's minimum splash of a second and a half after every trip load. It no longer does: while the trip loads it shows only its background, marked as a route fallback, so the page transition holds the dashboard until the trip is there and the trip slides in whole, as the dashboard slides back on the way out. The splash only comes up when a load takes longer than a second. The trip opened before, still in the store for a frame, is never shown for the one opening.
2026-11-08 10:33:49 +01:00
Maurice 3458238db0 feat(native): a link shared to TREK can go to the trip's links
A page or a link shared from another app now carries its address. The app asks whether to keep it among the trip's links (Collab, Links) or to read a booking from it. A link whose page the phone cannot read is no longer refused: it is handed over as the link alone, which can be kept. With Collab off a page is read as a booking, as before.
2026-11-08 10:15:26 +01:00
Maurice afaacec61a fix(native): a tick on the watch, the phone or another member's device reaches every watch at once
The watch's snapshot was rebuilt every fifteen minutes, so a tick in the app or one arriving over the WebSocket did not reach the watch until then. It is now rebuilt as soon as the packing lists or the to-dos change in the offline tables. A tick on the watch goes as a message while the phone is in reach, which wakes the phone app, and a phone app in the background writes it to the server itself, so the trip's other devices hear of it at once instead of when the phone app is next opened.
2026-11-08 10:15:20 +01:00
Maurice 5f4fb5dae7 feat(mobile): the tour editor as a tab of its own on an iPad and an unfolded foldable
On the wide and split size classes the touch shell no longer sends the tours tab back to the plan. It gets the desktop's tour editor, wired over the shell's own planner (useTripPlannerPageOf): on split the trip's tours and the editor share a pane beside the map, on wide the pane and the map take turns. A phone keeps its tours in the places browser.
2026-11-08 09:58:33 +01:00
Maurice 788b1f63d0 fix(mobile): the trip rail's More wears the dock's ring while its sheet is open 2026-11-08 03:07:24 +00:00
Maurice bfb1b04f8d feat(mobile): TREK Studio opens over the journey on an iPad on its side and an unfolded Duo
Studio is a child route of the journey, opened on top of it. The touch
shell's journey now renders that child in the split size class, where the
header offers Studio beside the menu, as the desktop's bar does. The editor
is the desktop's: tapping a panel, adding an element and dragging it with a
finger work through its pointer events. On the phone and in a single pane
the journey stays alone, whatever the address says, and offers no Studio.
2026-11-08 03:05:56 +00:00
Maurice a77ec6e225 fix(ci): the track sheet reads the distance unit as a DistanceUnit, and knip's baseline drops the type useFollowTrack no longer leaves unused 2026-11-08 03:00:39 +00:00
Maurice 2851eb7afe feat(mobile): a Google Maps route is imported into a road trip day on an iPad and an unfolded Duo
The desktop's import dialog keeps its markup and moves its logic into
useGoogleRouteImport, unchanged: the link previewed, its stops in order with
the unresolved ones named, and the resolved ones appended to the day picked,
at least two, under one request id per opening and day. The large screens
get the same flow as a sheet, offered on the stage card under the track
offer to a member who may add places to days. The phone has no entry to it.
2026-11-08 02:57:43 +00:00
Maurice 6c7605b4a6 fix(mobile): the driving form's vehicle card says what you drive, not a second "Vehicle" under the range card 2026-11-08 02:54:39 +00:00
Maurice 3b286d04c3 feat(mobile): the tour detail edits on an iPad and an unfolded Duo
The phone's tour sheet reads the tour and plans it onto a day. In the wide
and split size classes it now edits what the desktop's tour card edits,
through the same useTourDetailActions: the name and the colour, a copy saved
as a new tour, the recorded tracks, the packing list it points at and its
files, for a member who may edit places. The phone is unchanged.
2026-11-08 02:45:34 +00:00
Maurice 21afe89b13 feat(mobile): a road trip day follows an imported track on an iPad and an unfolded Duo
The desktop's track dialog comes to the large screens as a sheet over the
planner's followTrack, the hook RoadtripTrackModal runs on: the trip's tracks
nearest to the day first, the drive fitted to the one picked with via points,
and those vias dropped again. The stage card offers it in the wide and split
size classes, to an editor of days, on a trip that holds a track, and names
the track the day follows. The phone and the folded Duo have no entry to it.
2026-11-08 02:42:37 +00:00
Maurice 060a43ba64 feat(mobile): the driving figures are a form on an iPad and an unfolded Duo
On the phone the road trip's driving figures stay a read-out, set at the
desktop. A large screen is that table, so in the wide and split size classes
the same sheet edits the two driving limits, the day's window and where it
ends, the vehicle with its range and fill, and what the route may avoid,
through the planner's saveRoadtripLimit, the desktop dialog's own save. A
range worked out from the vehicle's own figures stays read-only, as on the
desktop, and a reader without the right to edit days sees every field
disabled. The phone and the folded Duo are unchanged.
2026-11-08 02:37:26 +00:00
Maurice dedbf16f03 fix(native): Android offers TREK Cloud while orders are closed and says a refused purchase is refunded
The licence testers of the internal test buy from a release build, so the
plan screen no longer hides the purchase on Android before the opening.
Their purchases are Play test purchases, which the cloud serves. A real
purchase made before the opening is refused with ordering-closed and never
acknowledged, so Google Play refunds it after three days; the claim and the
restore now say that instead of a general error. The app still acknowledges
a purchase only after a redeem that returned an instance. iOS is unchanged.
2026-11-08 02:31:13 +00:00
Maurice 796f39fe1f fix(rtl): write the isolate marks of flowText as escapes, not as raw characters
A raw FSI or PDI in the source reads as nothing and is what Sonar's
bidirectional character rule (S6389) flags. The strings are the same.
2026-11-08 01:40:30 +00:00
Maurice ee9cbce23d fix(journey): the feed's entries are gone under the header pills, not half seen between them
Scrolled up, a day's head showed at a third of its strength between the
back button and the segment, because the feed's mask let content through
from 22px below the safe area while the pills reach 38px. It is now
transparent down to 40px and whole from 60px, where the first day's head
starts.
2026-11-08 01:11:01 +00:00
Maurice 82fcdcd0fc test(rtl): read rtl.css selectors with their white space folded
Prettier breaks the arrow rule's long selector over lines, so the literal
look-up found no rule and FE-RTL-CSS-003 failed after the file was
formatted.
2026-11-08 00:38:51 +00:00
Maurice 3b7cdf516a fix(mobile): the rail fades where its entries scroll on a short window; the watch capture says why it retries
On a wide window too short for every entry (a folded Duo on its side) the
rail's list scrolls with its scrollbar hidden, and settings and
administration at its foot were cut off without a sign. The list now fades
at the edge with more behind it, as other scrolling lists do.

watch-screens.sh names the step that failed (launch, not ready,
screenshot) and tries once more after 20 seconds before it gives up.
2026-11-08 00:33:07 +00:00
Maurice 41752887dc fix(journey): a folded Duo on its side gets the compact cards in the wide size class too
The folded Duo on its side is over 500px high and so in the wide size
class, whose large cards won over the short window's compact ones. Short
is now asked first.
2026-11-08 00:10:57 +00:00
Maurice b9d0d3397e style: format the six files the formatting check names
They are outside scripts/format-baseline.json, so new code there must be
formatted; Prettier only, no change in behaviour.
2026-11-08 00:10:57 +00:00
Maurice 2a2dfec247 fix(mobile): large screen polish for the dashboard, the trip dock, transports, the stay chip and the journey strip
- Dashboard: crossing the split size keeps every widget in the same parent
  (the columns dissolve into one ordered stack below it), so the currency
  converter is not remounted and keeps its amount and rates.
- Trip dock: More is marked with the main dock's ring while one of its
  sections (chat, notes, links, polls, files) is on show or it is open,
  instead of the filled circle of a tab.
- Transport cards: the status button is a flex box, so its dot shows when
  the card may be edited.
- Day header: the stay chip's name keeps its own direction, so a Latin name
  in an Arabic header is cut at its end.
- Journey: a folded Duo on its side (about 540px high) gets the compact
  cards, so the map keeps its room.
- Rail: a test pins settings and administration marking their own entry.
2026-11-07 23:31:54 +00:00
Maurice 21e07e20a1 fix(ci): keep index.css and useAtlas.ts within their size baselines
The opening pan now measures the north edge through a function chromePan
calls only where there is chrome to clear, so useAtlas needs no branch of
its own. index.css loses the long note on the dark field rule to one line,
and a .dark .bg-slate-100 rule that two later rules always overrode.
2026-11-07 23:31:54 +00:00
Maurice 322d5d5209 test(map): the phone hook mock keeps the number of hooks stable
The mock called the real useIsPhone only while a test left the answer open,
so a map still mounted when the answer was reset rendered with one hook more
and React threw. It now calls the real hook every time and overrides only its
answer.
2026-11-08 00:05:54 +01:00
Maurice 0346f9c13d refactor(mobile): the file preview's heading as its own part
The type tile beside the name took the preview pane past the complexity
limit; the heading (tile, name, type, wallet, size, date, uploader) is now a
part of its own and the pane reads as before.
2026-11-08 00:04:47 +01:00
Maurice dfd87d413e fix(atlas): measure the opening pan only where there is chrome to clear
The opening view asked Leaflet for the north edge's point on every atlas,
though only the touch shell's rail and header need a pan. The desktop atlas
now opens exactly as before, without the call, which its map mocks also
never had.
2026-11-08 00:02:20 +01:00
Maurice 27aab5c783 test(journey): wait for the dropped create parameter to reach the URL
The draft opens and the parameter goes in the same effect, but the URL shows
it a render later. Under CI load the check ran in between and saw
?create=entry; it now waits for the empty search like it waits for the draft.
2026-11-07 23:59:38 +01:00
Maurice a7cc1b9e57 fix(mobile): the checks the large-screen merge left red
- The trip's segmented control writes its fit through a ref, not into the
  element held in state (react-hooks/immutability).
- The amount field of a payment uses the body type tier, which follows the
  text size setting, instead of a raw size.
- The form sheet tests wait the frame the edge fade now measures on, and read
  the wide variant from the size class the shell writes.
- The native dashes spec lists its locales through node:fs, so shared needs no
  vite types for it (knip).
- Baselines lowered where the fixes removed warnings: no-misused-promises,
  no-non-null-assertion in tests, the MSheet rtl entry.
2026-11-07 23:56:33 +01:00
Maurice 8955790f17 fix(mobile): the trip's screens on an iPad and an unfolded foldable, nothing cut or covered
- Top bar: the segmented control keeps to the room between the back button
  and the trailing button, draws its segments tighter and then scrolls with
  faded edges instead of covering the back button. On a wide window every
  list tab puts its primary pill in the middle and its round buttons at the
  trailing end, so the pill no longer jumps between tabs.
- Day strip: a chevron over the faded end pages the days along on a wide
  window, and the fade reaches further; the chips keep clear of it.
- Plan pane: the day card ends on the rail's bottom line beside the map (as
  tall as its day across the page), scrolls inside a frame and fades the
  rows at its edges. Addresses and the hotel chip end in an ellipsis instead
  of being cut mid-letter; the weather chip keeps its width.
- Map: in the dark theme the map beside the pane takes the dark basemap.
- Places: the All / Unplanned / Planned chips are never narrower than their
  names and scroll with a faded edge; on a short screen Places/Tours shares
  their row.
- Chat: the list fades out under the floating controls instead of cutting a
  bubble. Notes and to-do chip rows fade at their edges and New Note stays
  outside the scrolling chips.
- Costs: the balance amounts share a line, the settle-up action wraps to a
  line of its own, the totals stand under their labels.
- Bookings and transports: one column until two cards fit, titles take two
  lines with the type under them, the time cell is never cut and a range
  names its unit once.
- Files: the preview pane shows a type tile beside the name where no PDF can
  be drawn in place, its actions keep their labels on one line and wrap as a
  row, link labels are badges, and a tile that opens its file is no toggle.
- Polls get a fill that differs from card and track, packing filters are two
  segmented controls, empty checkboxes get an edge with 3:1 contrast, and the
  middle dots between values are gone.
2026-11-07 23:25:16 +01:00
Maurice 61d925f3dd fix(mobile): the chat watches its list once it exists, keeps its draft, and the desktop map opens as before
- useCollabChatData: the resize observer that keeps the chat at its newest
  message attaches when the list is drawn (after the messages load), not at
  mount when there is no list yet. The test renders the real list after load.
- MCollabTab: the chat stays mounted, hidden, while a board fills the page on
  its own, so leaving two panes with Notes picked keeps the half typed
  message and the scroll position. The links card hugs its links on a wide
  window instead of a page-high empty card.
- openingView: the floored opening zoom and the second fit for the map's own
  size apply only beside the pane in the touch shell; the desktop planner
  and the phone open at Leaflet's nearest level again.
2026-11-07 23:25:16 +01:00
Maurice bf00412863 fix(mobile): Arabic reads in its direction: arrows turn round, Latin names keep their ends, figures stay whole
In the touch shell the back, forward, disclosure, date-range and flow
arrows kept pointing right in Arabic, so Settle up showed the debt the
wrong way round and Vacay's year arrows pointed inward. The shell now
mirrors lucide's direction arrows in right-to-left languages; an icon
that names its own treatment keeps it, and the lightbox arrows stay on
their physical sides.

Latin user text in Arabic rows lost its beginning when cut and moved its
punctuation to the wrong end. Names, titles, addresses and file names take
their direction from their own text (dir=auto) and still line up with the
row; markdown paragraphs, chat messages, poll options and journal stories
are isolated; signed balances, amounts, sizes, rates and leg figures read
as one left-to-right run; URL fields read left to right. Name pairs joined
by an arrow isolate each name and point from the first to the second.

The hero progress and poll result bars fill from the start edge. Arabic
and Hebrew uppercase caption labels grow a step and take the muted ink,
and a todo's due date in a notification is written in the reader's
language instead of as YYYY-MM-DD.
2026-11-07 23:25:16 +01:00
Maurice 2a6c771ad9 fix(native): the first screens fit a phone on its side, fill an upright iPad, and read right in Arabic and Russian
On a phone or a folded foldable turned landscape the welcome, address,
TREK Cloud and plan screens were taller than the glass: the second choice
and the main button sat below the edge and Back was cut at the top. There
the explanation and the choices now stand side by side, the explanation
pinned while a long plan list scrolls. Every screen centres while it fits
and starts at the top when it does not.

Upright on an iPad, half of one in Split View and an unfolded foldable,
the photo spans the full width and fades only downwards, and the choices
stand centred in the room below it. Buttons and fields are pills like the
app's, choice cards take the app's card radius, and headings and subtitles
balance their lines.

In Arabic (and Hebrew, which never ran right to left here) chevrons point
along the reading direction and sit at the far edge of their card, the
Cloud lockup stays unmirrored on the leading edge, prices and addresses
read left to right, and a plan's period no longer touches its price. The
slides follow the reading direction. Russian gets Cyrillic stand-ins under
the same family names, so the headline keeps a rounded face.
2026-11-07 23:25:15 +01:00
Maurice 641b1882b5 fix(i18n): the app's first screens read without dashes in every language
The welcome subtitle and the TREK Cloud texts used an em dash as a pause in
all locales. Each is reworded with a comma, a colon or a new clause, and a
spec keeps a dash from coming back in any locale's native strings.
2026-11-07 23:25:15 +01:00
Maurice 5346182807 fix(mobile): the rail, the docks and the top-level screens on a large screen
From the large-screen review (visual findings of the shell and the top-level
screens):

- Rail: 80px wide, one caption size for every name (no shrinking per item),
  balanced two-line names with every entry as tall as the tallest, short rail
  names where a language's word is too long (mobileNav.rail*, all locales),
  and near-opaque glass so a map's colours no longer smear through it.
- Rail head: always one 56px circle, so the entries never jump between pages:
  the create action, the screen's own action (the vacation calendar's
  year/editor switch moves there instead of floating over the days) or the
  quiet logo on settings and administration. It stays put while a short rail
  scrolls.
- Vacation calendar: weekday letters over each month on a wide window; the
  editor's mode switch sits at the foot with room kept for it.
- Docks: More is marked with a ring rather than the centre button's fill, the
  dock glass is denser, and lists fade out under the dock instead of showing
  in the gap below it.
- Dashboard: trip names get the card's width with the dates under them, the
  hero's facts wrap as whole chips, the filter chips scroll with a fade, the
  hero ends above the dock on a phone on its side and its buttons sit on a
  dark ground; the header bar stands in the content's column.
- Journeys: on a wide window the header is the same glass bar, in the column
  of the cards, and the other journeys' label starts with them.
- Notifications: the round chevron back button, in the list's column.
- Settings and administration share one frame and width. Admin's new-user
  action sits under the section list's title, the users card header wraps
  inside the card, stat labels ellipsize, role badges read in dark mode.
- Collections: one card per place with the status inside it, worded in a
  readable mix of its colour.
- Atlas and the journey map frame clear of the rail and the header; the
  journey's day bar sits on a glass plate, and a phone on its side gets
  smaller cards.
- Rail label tests rewritten for the new rule; FE-MOB-PANES-016 duplicate
  renumbered to 017.
2026-11-07 23:25:15 +01:00
Maurice 4dfccaa2a4 fix(native): one size class the keyboard cannot move, shortcuts that wait for sheets
From the large-screen review (code findings H1, M1, M2, M3, M4, M7, L1, L3, L4, L6, L7):

- The size class is one value in JavaScript (hooks/sizeClass.ts) and the
  stylesheet follows it: MobileShell writes it to the shell as data-size, the
  wide:/split: variants and mobile.css key on that instead of media queries of
  their own. A window that only grows shorter at the same width while a field
  has focus is Android's soft keyboard, not a new window, so the rail, the form
  sheets and the panes no longer fold away (and take the focused field with
  them) while one types.
- The large-screen layout is the app's only. In a browser the touch shell is
  the phone's below 768px and stays compact there, as before this branch.
- Shortcuts are the app's only, so a browser keeps Ctrl+1 and Alt+Left. The
  command key is the platform's own (Cmd on Apple, Ctrl elsewhere), digits are
  matched by the key's place as well (AZERTY), and nothing runs while a sheet
  or another modal dialog is open: the page listens to nothing and the app is
  handed no key commands, so Cmd+[ can no longer drop an unsaved form.
- Inside a trip Cmd+N belongs to the trip shell and creates what the tab on
  show creates, named after it.
- Inside the touch shell the desktop navbar of the few PageShell routes is
  hidden and --nav-h is 0.
- The rail comes before the page in the DOM, so a keyboard reaches it first.
- The phone's deep day colour for a journey card without a photo stays the
  phone's; the desktop and shared timelines keep their faint wash. Its card is
  opaque, a suggestion included, so the map's names do not read through.
- The trip widget's iPad mini layout keys on the iPad mini's width alone.
- The trip dock gets the scroll-edge strip and the denser dock glass the
  global dock has.
- Duplicate test id FE-MOB-PANES-016 renumbered.
2026-11-07 23:25:14 +01:00
Maurice 18a16d2aa2 fix(mobile): forms on the shared form sheet, and the details the review found
Expense, payment, booking, transport, day note, place edit, new trip,
journey entry, collab note, poll, task, packing item and packing import
use the form sheet: one header with its icon tile, a body that scrolls
with a fade, the footer, and full height with the actions beside the
title on a short window. Create New Trip loses its nested section cards.

Details:
- Add payment gets its icon tile and the currency inside the amount field.
- The expense's category field has the same fill as every other field.
- Day and date fields keep their year at phone width; From and To stack
  in a narrow form.
- The place sheet's category chip sits under the name, uncut, the photo
  on the cards' leading edge, labels in the sheets' small capitals.
- Stars no longer keep a hover preview after a tap.
- A hotel booking shows its stay's check-in and check-out when it has
  none of its own, instead of the grey hint reading as a saved time.
- The collab note's title field is labelled Title (common.title).
- Shorter airport hint in German and Russian; long hints end in an ellipsis.
- The journey story grows with its lines (useAutoGrow).
- Member avatars show the member's initial, yours too, and turn over on
  a selected chip.
- The trip's More sheet scrolls, dims a short window and is marked in
  the dock while open; long titles wrap to two lines.
2026-11-07 23:25:14 +01:00
Maurice b7a91fc84e fix(mobile): one form sheet for every dialog, opaque and placed beside the rail
Every sheet now stands on the opaque sheet surface: the frosting the
translucent one relied on is dropped while the scrim fades, so the page
read through the fields as sharp ghost text.

On a wide window a sheet is centred in the content beside the navigation
rail instead of on the whole window, so it never lies on the rail, and it
grows to the window's height instead of stopping at 880px. On a phone the
card stands between the status bar and the dock rather than across the
dock. A sheet marked as a form fills the height of a window as short as a
phone on its side, with Cancel and Save beside the title (new `short:`
variant, m-form-sheet grid in mobile.css).

The form chrome gains a scrolling body that fades at the edges with more
behind them (useEdgeFade on the vertical axis), a date field whose
keyboard entry moves into the picker, a field wrapper for amounts with a
currency, and two-line titles. The focused sheet no longer draws the
app-wide focus frame or loses its corners, and the desktop's dark field
paint stays out of the touch shell, which painted a dark box inside every
rounded field.
2026-11-07 23:25:14 +01:00
Maurice bb725ac8d8 fix(native): the first screens on an upright iPad read as one page, not a phone column
On an iPad upright or an unfolded foldable the welcome, address, TREK Cloud
and plan screens kept the phone's narrow column in the middle of the glass,
with the photo fading out across the top left. Now the photo spans the
width as a band across the top, the choices stand under it in a wider
column, and the plans take two columns. Landscape keeps the photo as a
panel beside the choices; phones are unchanged.
2026-11-07 23:25:14 +01:00
Maurice b644235eb6 fix(mobile): level card columns, the day framed beside the plan, the reader's place kept across a fold
From a screen by screen pass over the iPad and foldable sizes:

- Card lists (transports, bookings, tasks, polls) flow down their columns
  instead of filling rows, so the columns start level and a short card no
  longer ends above its neighbour in a ragged row. The cards' own top
  margin gives way to an even gap under each, so every column starts on
  one line.
- A booking's or transport's title may take two lines in a column of a wide
  window instead of being cut after a few words.
- The map beside the plan opened too close: the opening frame was worked
  out for the window, not the narrower map, and Leaflet rounded its zoom up
  to the next whole level, so the day ran off the top and bottom. The map
  now opens at the whole level that fits and, once it knows its own size,
  works the frame out again for it (OpeningFrameFit). On a wide window the
  touch shell frames a day clear of its floating toolbar and corner
  buttons.
- Costs and to-dos in two panes are one element tree with the stacked
  arrangement, so a rotation or a fold between them keeps what is typed or
  open in either pane, and the row at the top of the list stays at the top.
- The place sheet's category pill has room for its name on a wide window.
2026-11-07 23:25:13 +01:00
Maurice d259ecb11c fix(ios): widgets hold at an iPad mini's sizes, the extra large ones balanced
From the iPad renders of the widget previews:

- An iPad mini's small and medium widgets are smaller than an iPhone's. The
  trip widget during the trip lets the weather give way in the small one and
  narrows its stops panel in the medium one, the today widget's weather
  shrinks to its chip and then steps aside before the trip's name is cut,
  the atlas puts "countries" under the number in the small one and lets
  its map shrink in the large one, so the continents and latest countries
  are no longer cut off at the foot.
- The extra large widgets fill their width and height: the trip during the
  trip sets the next stop at the foot of the left column and the next
  booking under the day's stops, today sets the next two bookings across
  the foot, the bookings list spaces its rows beside the card.
- The Live Activity's next stop shrinks a little before it is cut.
2026-11-07 23:25:13 +01:00
Maurice 5d05363f36 feat(ios): the iPad gets the extra large widgets, no Wallet button, a centred share sheet
The app is one binary for iPhone and iPad, so what was built for the
iPhone has to hold on an iPad too:

- Widgets: the trip, today and bookings widgets offer the iPad's extra large
  size. The trip sets its countdown or its day beside the bookings or the
  day's stops, today sets the day, its progress and weather beside the
  timeline, bookings sets the next one as a card beside the rest. The other
  widgets stay at the sizes they were designed for.
- The widget renderer draws every home-screen size of each widget as a 13"
  iPad, an 11" iPad and an iPad mini give it (a mini's small widget is
  smaller than an iPhone's), an iPad lock screen with the accessories and
  the travel day's Live Activity, and that Live Activity at the iPhone's and
  the iPad's widths. To draw it there, its lock-screen banner takes plain
  values (TripDayBanner, now beside the today widget).
- Apple Wallet: an iPad cannot take a pass. The shell reports the wallet
  feature only where PKPassLibrary and PKAddPassesViewController can add
  one, so the UI shows no "Add to Apple Wallet" button there and hands a
  .pkpass to the share sheet like any other file.
- Share extension: on an iPad it is presented over the full screen instead
  of in a form sheet, and in a window of regular width its card stands
  centred over the dimmed app, without the grabber, instead of rising from
  the bottom edge. A narrow Split View window keeps the iPhone's card.
2026-11-07 23:25:13 +01:00
Maurice 6906f80ff8 feat(native): Android tablets get the shortcuts, two panes meet at a foldable's fold
The Android shell now reports keyCommands and windowLayout, so the large
screen layout no longer assumes an iPad:

- The keyboard host hands the shortcuts to the app whenever the shell has
  the keyCommands feature, whatever the platform. Before it also required
  iOS, so an Android tablet with a keyboard answered the keys in the page
  and never listed them in the system's shortcuts.
- useWindowFold follows the layout the Android shell reports (feature
  windowLayout). When a vertical fold separates the window, a foldable half
  open like a book, MobileShell marks it with data-fold and its place, and
  the leading pane (the trip's plan, a tab's summary, a list beside its
  detail) ends at the fold while the trailing one starts there, on either
  reading direction. A flat fold that does not separate stays one screen.
- The split size class starts at 840pt, Android's expanded width, instead
  of 900pt, so a foldable unfolded on its side (about 841dp) gets two panes
  like an iPad does. An 11" iPad upright (834pt) stays a single pane. The
  address screen of the native shell follows the same width.
- TrekShell exports NativeWindowLayout for it.

Also: the sheet's width and anchor types and the collab board type stay
private to their modules, which nothing else imports (knip).
2026-11-07 23:25:13 +01:00
Maurice 7083667cbf fix(mobile): grid cards keep their height, a previewed photo its proportions
Cards in the large-screen grid keep their own height instead of stretching
to a taller neighbour in their row, which drew a short booking as an empty
panel. The file preview beside the grid shows a photo at its own
proportions rather than letterboxed in a portrait frame.

On a 13" iPad on its side the cloud plans of the native shell stand two by
two in their half of the screen, so the whole offer is in view at once.
2026-11-07 23:25:12 +01:00
Maurice 0b7cee0b0d feat(mobile): the trip's tabs, administration, collections and the journal use the large screen
On an iPad or an unfolded foldable the list screens kept one phone-wide
column in the middle of the page. They now lay out for the room they have:

- Transports, bookings, polls and tasks put their cards in a grid of as many
  phone-wide columns as the page holds (CardGrid), three on a 13" iPad on
  its side, two upright, one in a narrow Split View.
- Costs: with room for two panes (split size class) the summary (total,
  owe and owed, settle up, final budget, categories) stands in a pane of its
  own beside the expenses, each scrolling on its own (TabPanes). The search
  field sits in the expenses' column again instead of running past it.
- Files: a grid of tiles with each photo's picture; with room for two panes
  the picked file is previewed beside the grid with what it belongs to and
  its actions, the way Files on an iPad does. The signed preview address
  comes through fileRepo.previewUrl.
- Packing: the categories stand in columns, each card whole in its column;
  the action menu drops from its button at a popover's width.
- To-dos: the four smart filters as tiles and the trip's lists in a pane
  beside the tasks, which take the filter's name as their head.
- Collab: the chat stays in view at the trailing edge beside the notes,
  links or polls, and the header switches only the board. The chat keeps its
  place in the tree, so a fold or a rotation into two panes keeps the draft;
  a chat at its newest message stays there when its box changes size. Notes
  stand as a board in columns.
- Administration and settings share a section list beside the page
  (MSectionNav); the stats grid takes four columns.
- Collections: the lists stand in a column beside the places, which take two
  columns, and the head names the list on show.
- Journal: the entries become a feed beside the map, under their days, with
  photo, place and the start of the story, in step with the map like the
  strip of cards it replaces; it fades out under the header.
- The assistant opens as a tall panel at the trailing edge over a dimmed
  page, as Slide Over would, instead of covering the whole screen.
- The rail fits every name: a word too wide for it shrinks the label before
  it is hyphenated, and the admin entry uses its short name.
- The journeys and notifications headers keep to their column.
- The address screen of the native shell sets the photo as a panel beside
  the choices on a large screen on its side.

On a foldable that reports its fold, a leading pane ends at the fold and a
trailing one starts there. Phones keep their layout.
2026-11-07 23:25:12 +01:00
Maurice 36ed0e0d23 fix(mobile): the map frames the day beside the pane, atlas search as a centred panel
With the plan beside the map, the map frames the day once the splash has
gone, and again once its box has settled after the pane opens or hides, a
rotation or a fold: a frame fitted to the old box left the day half outside
the new one. On a wide window the atlas country search stands as a centred
panel instead of a band across the screen.
2026-11-07 23:25:12 +01:00
Maurice b1c0353548 feat(native): iPad shortcuts in the app menu, pointer input on iPad
The UI's keyboard shortcuts for the screen on show become native key
commands (TrekShell.setKeyCommands, feature keyCommands): they appear in the
overlay a held Command key brings up and in the iPadOS menu bar, named in the
UI's language, and a press is reported back to the page ("keyCommand").
Each screen replaces the list, so only what works there is ever offered.

UIApplicationSupportsIndirectInputEvents turns on the iPad's pointer input
for trackpads and mice.
2026-11-07 23:25:12 +01:00
Maurice c204a25a9c feat(mobile): the touch shell laid out for iPad and unfolded foldables
The app shows the touch shell at every size now, an iPad's and an unfolded
foldable's included, instead of switching to the desktop pages from 768px.
One route tree survives rotation, Split View, Stage Manager and folding, and
the app's own features (Wallet, sharing into a trip, the edge swipe back)
are there on the big screen too.

Two size classes drive the layout (hooks/sizeClass.ts, Tailwind variants
wide: and split:):

- wide (600pt and taller than a phone on its side): a glass navigation rail
  on the leading edge replaces the dock and holds every destination with its
  name; sheets become centred form sheets; the trip's More menu is a popover
  beside the rail; lists keep a readable measure; dashboard, journey and
  vacay get columns; full-bleed maps run under the floating rail.
- split (900pt): the trip's plan, places or road trip chain sit in a pane
  beside the map, which follows the plan's day; the list/map switch hides
  and shows the pane like a sidebar; on a foldable that reports its fold the
  pane ends at the fold. Settings show their sections beside the page and the
  dashboard keeps its widgets in a column of their own.

A hardware keyboard gets the shortcuts an iPad app has (⌘1 to ⌘9 for the
sections, ⌘N, ⌘, , ⌥←/⌥→ through the days, ⌃⌘S for the pane, ⌘[ back),
answered by the page or, with a binary that has the keyCommands feature,
handed to it as native key commands.

The day strip fades at an edge that runs on, journey cards grow on a wide
window and a card without a photo takes the day's colour, and the Leaflet
map measures itself again when its box changes size.
2026-11-07 23:25:12 +01:00
Maurice b008e010a5 fix(native): sort the offline manifest with localeCompare, so Sonar's reliability gate holds 2026-11-07 22:03:20 +01:00
622 changed files with 24040 additions and 2640 deletions
+3 -1
View File
@@ -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}"
+35
View File
@@ -121,6 +121,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 +151,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 +211,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 +240,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: |
+4 -2
View File
@@ -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"
+3
View File
@@ -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 && \
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 5.1.0
version: 5.1.2
description: Minimal Helm chart for TREK app
appVersion: "5.1.0"
appVersion: "5.1.2"
@@ -9,7 +9,9 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-action-sheet')
implementation project(':capacitor-app')
implementation project(':capacitor-dialog')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
@@ -36,7 +36,7 @@ public class MainActivity extends BridgeActivity {
// scroller natively. It scales the whole view, top bar and bottom dock included.
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
// Set before any WebView callback can run: they are posted to this thread.
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen, TrekServerStore.getSignInHosts(this)));
TrekSharedFiles.receive(this, getIntent());
// Android 15's widget picker draws the widgets live from a sample trip.
TrekWidgetUpdater.publishPreviews(this);
@@ -0,0 +1,57 @@
package com.liketrek.trek;
import java.net.URI;
import java.util.Locale;
/**
* A sign-in page in front of the server that the address screen never saw.
* A proxy can let the app's check through (an Authelia or Authentik rule that
* bypasses /api) and still send the page itself to its sign-in. Handed to the
* browser, that sign-in leaves its session there and the app stays blank on
* every launch. So a redirect away from the server, while the server's first
* page is still loading, is taken as the proxy's sign-in: it loads in the web
* view and its host is kept, the way the address screen keeps the ones it saw.
* A link the user follows is no redirect, and once a page of the server has
* shown, nothing is learned any more.
*/
final class SignInRedirect {
private SignInRedirect() {}
/** The host (host:port with an explicit port) to keep as a sign-in host, or null. */
static String learnedHost(String server, String target, boolean mainFrame, boolean redirect, boolean serverShown) {
if (!mainFrame || !redirect || serverShown || server == null || target == null) return null;
URI from;
URI to;
try {
from = new URI(server);
to = new URI(target);
} catch (Exception e) {
return null;
}
String fromHost = lower(from.getHost());
String toHost = lower(to.getHost());
String toScheme = lower(to.getScheme());
if (fromHost == null || toHost == null) return null;
if (!"https".equals(toScheme) && !"http".equals(toScheme)) return null;
// Never from https down to plain http: the sign-in would send a password in the clear.
if ("https".equals(lower(from.getScheme())) && !"https".equals(toScheme)) return null;
if (toHost.equals(fromHost)) return null;
return to.getPort() == -1 ? toHost : toHost + ":" + to.getPort();
}
/** Whether `url` is on the server's host, where the app's own pages come from. */
static boolean onServer(String server, String url) {
if (server == null || url == null) return false;
try {
String serverHost = lower(new URI(server).getHost());
return serverHost != null && serverHost.equals(lower(new URI(url).getHost()));
} catch (Exception e) {
return false;
}
}
private static String lower(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT);
}
}
@@ -10,6 +10,10 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import org.json.JSONException;
import org.json.JSONObject;
@@ -18,26 +22,80 @@ final class TrekServerStore {
private static final String PREFS = "trek_shell";
private static final String KEY_URL = "server_url";
private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts";
private static final String KEY_SHELL_ONCE = "shell_once";
private static final String KEY_RESTART = "restart";
private static final String KEY_WELCOME = "welcome";
private static final String CONFIG_DIR = "trek-server-config";
private TrekServerStore() {}
/**
* The one-time code of a TREK Cloud purchase (setServer's `welcome`). The
* server's first page opens its welcome page with it once, which signs the
* buyer in (TrekWebViewClient). Kept only until the restart has loaded.
*/
static void setWelcome(Context context, String code) {
SharedPreferences.Editor editor = prefs(context).edit();
if (code == null) editor.remove(KEY_WELCOME);
else editor.putString(KEY_WELCOME, code);
editor.commit();
}
/** The welcome page for the pending code, handed out once; null when there is none. */
static String takeWelcomeUrl(Context context) {
String code = prefs(context).getString(KEY_WELCOME, null);
String server = getUrl(context);
if (code == null) return null;
prefs(context).edit().remove(KEY_WELCOME).commit();
if (server == null) return null;
return (server.endsWith("/") ? server : server + "/") + "api/managed/welcome#" + code;
}
static String getUrl(Context context) {
return prefs(context).getString(KEY_URL, null);
}
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
static void setUrl(Context context, String url) {
setServer(context, url, null);
}
static void setServer(Context context, String url, Set<String> signInHosts) {
SharedPreferences.Editor editor = prefs(context).edit();
if (url == null) {
editor.remove(KEY_URL);
} else {
editor.putString(KEY_URL, url);
}
if (signInHosts == null || signInHosts.isEmpty()) {
editor.remove(KEY_SIGN_IN_HOSTS);
} else {
Set<String> lower = new HashSet<>();
for (String host : signInHosts) lower.add(host.toLowerCase(Locale.ROOT));
editor.putStringSet(KEY_SIGN_IN_HOSTS, lower);
}
editor.commit();
}
/**
* The hosts of a sign-in page that stands in front of the server (a proxy
* such as Cloudflare Access or Pangolin), as the address screen found them.
* Only these may load inside the web view besides the server itself, so
* the proxy's session lands where the app can use it (TrekWebViewClient).
*/
static Set<String> getSignInHosts(Context context) {
Set<String> hosts = prefs(context).getStringSet(KEY_SIGN_IN_HOSTS, null);
return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts));
}
/** Keeps one more sign-in host for the remembered server, as the web view met it (SignInRedirect). */
static void addSignInHost(Context context, String host) {
Set<String> hosts = new HashSet<>(getSignInHosts(context));
if (!hosts.add(host.toLowerCase(Locale.ROOT))) return;
prefs(context).edit().putStringSet(KEY_SIGN_IN_HOSTS, hosts).commit();
}
/** Start the next launch on the bundled address screen, even with a server remembered. */
static void showShellOnce(Context context) {
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
@@ -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);
}
@@ -6,17 +6,25 @@ import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.activity.result.ActivityResult;
import com.liketrek.trek.clientcert.ClientCertActions;
import com.liketrek.trek.clientcert.TrekClientIdentity;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationManagerCompat;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.PermissionState;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission;
import com.getcapacitor.annotation.PermissionCallback;
import com.google.firebase.messaging.FirebaseMessaging;
import java.util.HashSet;
import java.util.Set;
import org.json.JSONObject;
/** The native half of client/src/native/trekShell.ts. */
@@ -29,6 +37,8 @@ import org.json.JSONObject;
)
public class TrekShellPlugin extends Plugin {
private static final String TAG = "TrekShell";
/** How long to wait after returning from the browser for the redirect to land. */
private static final long CANCEL_GRACE_MS = 600;
@@ -59,6 +69,7 @@ public class TrekShellPlugin extends Plugin {
"googleWallet",
"keyCommands",
"windowLayout",
"clientCertificate",
};
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
@@ -272,11 +283,26 @@ public class TrekShellPlugin extends Plugin {
call.reject("A http(s) URL is required");
return;
}
TrekServerStore.setUrl(getContext(), url);
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
String welcome = call.getString("welcome");
TrekServerStore.setWelcome(getContext(), welcome != null && welcome.matches("[A-Za-z0-9_-]{43}") ? welcome : null);
call.resolve();
restart();
}
/** The sign-in hosts the address screen passes along, plain host names only. */
private static Set<String> signInHostsOf(PluginCall call) {
Set<String> hosts = new HashSet<>();
JSArray list = call.getArray("signInHosts");
if (list == null) return hosts;
for (int i = 0; i < list.length(); i++) {
String host = list.optString(i, "");
if (host.matches("[A-Za-z0-9.-]+(:[0-9]+)?")) hosts.add(host);
}
return hosts;
}
@PluginMethod
public void resetServer(PluginCall call) {
TrekServerStore.setUrl(getContext(), null);
@@ -315,7 +341,32 @@ public class TrekShellPlugin extends Plugin {
// A Custom Tab shares the browser's cookies, so a running SSO session at
// the identity provider carries over. The redirect back to the app's
// scheme arrives through the intent-filter in AndroidManifest.xml.
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url));
Uri target = Uri.parse(url);
getActivity().runOnUiThread(() -> openSignIn(target));
}
/**
* Opens the sign-in in a Custom Tab, or in the browser itself where no
* Custom Tab can come up. Launched on the UI thread: some devices dropped a
* launch from the bridge's thread without a word, and the sign-in then
* looked cancelled. A browser that cannot open at all is reported as such.
*/
private void openSignIn(Uri target) {
try {
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), target);
return;
} catch (RuntimeException tabFailed) {
Log.w(TAG, "No Custom Tab for the sign-in, trying the browser", tabFailed);
}
try {
getActivity().startActivity(new Intent(Intent.ACTION_VIEW, target).addCategory(Intent.CATEGORY_BROWSABLE));
} catch (RuntimeException browserFailed) {
Log.w(TAG, "No browser for the sign-in", browserFailed);
PluginCall call = pendingAuthCall();
if (call == null) return;
call.reject("No browser could open the sign-in", "NO_BROWSER");
finishAuth(call);
}
}
@Override
@@ -630,4 +681,129 @@ public class TrekShellPlugin extends Plugin {
// The last layout WindowManager reported, posture and hinge included; the bare window before the first.
getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.current(getActivity())));
}
// ---- Client certificates (mutual TLS) ----
/**
* The address screen's check of a server, made natively so it notices
* whether the server asks for a client certificate and can show the stored
* one (clientcert/ClientCertActions.request).
*/
@PluginMethod
public void requestServer(PluginCall call) {
String url = call.getString("url");
Uri parsed = url == null ? null : Uri.parse(url);
if (parsed == null || parsed.getHost() == null || !("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()))) {
call.reject("A http(s) URL is required");
return;
}
int timeout = Math.max(1000, Math.min(call.getInt("timeoutMs", 10000), 30000));
new Thread(() -> {
try {
call.resolve(ClientCertActions.request(getContext(), url, timeout));
} catch (Exception e) {
call.reject(e.getMessage() == null ? "request failed" : e.getMessage());
}
}, "trek-request-server").start();
}
/** The stored certificate's details, or none. */
@PluginMethod
public void clientCertificate(PluginCall call) {
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
JSObject result = new JSObject();
result.put("certificate", info == null ? JSONObject.NULL : ClientCertActions.infoObject(info));
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
if (asked != null) {
JSObject request = new JSObject();
request.put("host", asked.getHost());
request.put("port", asked.getPort());
request.put("served", asked.getServed());
result.put("request", request);
}
call.resolve(result);
}
@PluginMethod
public void removeClientCertificate(PluginCall call) {
TrekClientIdentity.remove(getContext());
forgetCertificateAnswers();
call.resolve();
}
/** The WebView keeps its answer to a certificate request per host; a changed certificate needs a fresh one. */
private void forgetCertificateAnswers() {
com.liketrek.trek.clientcert.ClientCertWatch.reset();
getActivity().runOnUiThread(() -> android.webkit.WebView.clearClientCertPreferences(null));
}
/**
* Picks a .p12/.pfx file, asks for its password until it opens or the user
* gives up, and keeps it for the server at host and port. Resolves with
* result "imported" (and the certificate), "cancelled", or "failed" and why.
*/
@PluginMethod
public void importClientCertificate(PluginCall call) {
String host = call.getString("host");
if (host == null || !host.matches("[A-Za-z0-9.-]+")) {
call.reject("A host is required");
return;
}
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*");
pick.putExtra(Intent.EXTRA_MIME_TYPES, new String[] { "application/x-pkcs12", "application/pkcs12", "application/octet-stream" });
startActivityForResult(call, pick, "clientCertificatePicked");
}
@ActivityCallback
private void clientCertificatePicked(PluginCall call, ActivityResult picked) {
if (call == null) return;
Uri uri = picked.getData() == null ? null : picked.getData().getData();
if (picked.getResultCode() != android.app.Activity.RESULT_OK || uri == null) {
resolveImport(call, "cancelled", null);
return;
}
new Thread(() -> {
byte[] bytes = ClientCertActions.readFile(getContext(), uri);
if (bytes == null) resolveImport(call, "failed", TrekClientIdentity.Failure.FORMAT.name());
else askPasswordFor(call, bytes, false);
}, "trek-client-cert").start();
}
private void askPasswordFor(PluginCall call, byte[] bytes, boolean wrong) {
JSObject labels = call.getObject("labels", new JSObject());
ClientCertActions.askPassword(getActivity(), labels, wrong, password -> {
if (password == null) {
resolveImport(call, "cancelled", null);
return kotlin.Unit.INSTANCE;
}
new Thread(() -> openWith(call, bytes, password), "trek-client-cert").start();
return kotlin.Unit.INSTANCE;
});
}
private void openWith(PluginCall call, byte[] bytes, char[] password) {
try {
TrekClientIdentity.Identity identity = TrekClientIdentity.read(bytes, password);
TrekClientIdentity.save(getContext(), identity, call.getString("host"), call.getInt("port", 443));
forgetCertificateAnswers();
resolveImport(call, "imported", null);
} catch (TrekClientIdentity.ImportException e) {
if (e.getReason() == TrekClientIdentity.Failure.PASSWORD) askPasswordFor(call, bytes, true);
else resolveImport(call, "failed", e.getReason().name());
} catch (Exception e) {
Log.w(TAG, "client certificate not kept", e);
resolveImport(call, "failed", "STORE");
} finally {
java.util.Arrays.fill(password, '\0');
}
}
private void resolveImport(PluginCall call, String result, String failure) {
JSObject out = new JSObject();
out.put("result", result);
if (failure != null) out.put("failure", failure.toLowerCase(java.util.Locale.ROOT));
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
if ("imported".equals(result) && info != null) out.put("certificate", ClientCertActions.infoObject(info));
call.resolve(out);
}
}
@@ -1,11 +1,17 @@
package com.liketrek.trek;
import android.net.Uri;
import android.webkit.ClientCertRequest;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient;
import com.liketrek.trek.clientcert.TrekClientIdentity;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/**
* Used while the WebView shows the user's server. When that server cannot be
@@ -18,10 +24,58 @@ import com.getcapacitor.BridgeWebViewClient;
class TrekWebViewClient extends BridgeWebViewClient {
private final Runnable onUnreachable;
private final Set<String> signInHosts;
/** A page of the server has shown since launch; from then on no sign-in host is learned (SignInRedirect). */
private boolean serverShown;
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) {
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
super(bridge);
this.onUnreachable = onUnreachable;
this.signInHosts = new HashSet<>(signInHosts);
}
/**
* The sign-in page of a proxy in front of the server loads here rather than
* in the browser, so its session cookie is the web view's and every request
* of the app carries it, as in a browser. Only the hosts the address screen
* saw that proxy send it to (TrekServerStore.getSignInHosts), and only as a
* page: Capacitor injects its bridge into the server's origin alone, so the
* proxy's page gets no access to the app. Everything else goes on to
* Capacitor, which opens a foreign page in the browser.
*/
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false;
String learned = SignInRedirect.learnedHost(
TrekServerStore.getUrl(view.getContext()),
request.getUrl().toString(),
request.isForMainFrame(),
request.isRedirect(),
serverShown
);
if (learned != null) {
signInHosts.add(learned);
TrekServerStore.addSignInHost(view.getContext(), learned);
return false;
}
return super.shouldOverrideUrlLoading(view, request);
}
/** After the server's first page, a pending welcome code opens the welcome page once (TrekServerStore.takeWelcomeUrl). */
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
if (SignInRedirect.onServer(TrekServerStore.getUrl(view.getContext()), url)) serverShown = true;
String welcome = TrekServerStore.takeWelcomeUrl(view.getContext());
if (welcome != null) view.loadUrl(welcome);
}
private boolean isSignInHost(Uri url) {
String scheme = url.getScheme();
String host = url.getHost();
if (host == null || !("https".equals(scheme) || "http".equals(scheme))) return false;
String key = host.toLowerCase(Locale.ROOT);
return signInHosts.contains(key) || (url.getPort() != -1 && signInHosts.contains(key + ":" + url.getPort()));
}
/** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */
@@ -34,6 +88,28 @@ class TrekWebViewClient extends BridgeWebViewClient {
return super.shouldInterceptRequest(view, request);
}
/**
* A proxy in front of the server asks for a client certificate (mutual TLS).
* The stored one goes only to the host and port it was imported for; every
* other site that asks is told there is none, as a browser without a
* matching certificate would. The WebView remembers either answer for the
* host until the app restarts, which a new certificate always brings along.
*/
@Override
public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) {
TrekClientIdentity.Identity identity = TrekClientIdentity.identityFor(view.getContext(), request.getHost(), request.getPort());
TrekClientIdentity.noteRequest(request.getHost(), request.getPort(), identity != null);
if (identity != null) request.proceed(identity.getKey(), identity.getChain());
else request.ignore();
// The page may still come from the service worker's cache while every
// request fails: check natively whether the server lets the app in.
String server = TrekServerStore.getUrl(view.getContext());
Uri serverUri = server == null ? null : Uri.parse(server);
if (serverUri != null && TrekClientIdentity.matches(String.valueOf(serverUri.getHost()), serverUri.getPort() > 0 ? serverUri.getPort() : 443, request.getHost(), request.getPort())) {
com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, onUnreachable);
}
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
if (request.isForMainFrame()) {
@@ -46,14 +122,22 @@ class TrekWebViewClient extends BridgeWebViewClient {
/**
* Only a 5xx on the page itself means the server is down (a proxy's 502 or
* 503 while TREK restarts). A 4xx page is left alone: the server answered,
* and TREK serves its UI for every other path.
* and TREK serves its UI for every other path. The exception is a proxy
* that asked this page for a client certificate and got none, as Cloudflare
* does with a 403: the address screen then says so and offers to add one.
*/
@Override
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
int status = errorResponse.getStatusCode();
if (!request.isForMainFrame()) {
super.onReceivedHttpError(view, request, errorResponse);
} else if (errorResponse.getStatusCode() >= 500) {
} else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) {
onUnreachable.run();
}
}
private static boolean wantedCertificate(Uri url) {
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
return asked != null && !asked.getServed() && TrekClientIdentity.matches(asked.getHost(), asked.getPort(), url.getHost(), url.getPort());
}
}
@@ -0,0 +1,151 @@
package com.liketrek.trek.clientcert
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.net.Uri
import android.text.InputType
import android.view.ViewGroup
import android.widget.EditText
import android.widget.FrameLayout
import com.getcapacitor.JSObject
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import javax.net.ssl.HttpsURLConnection
/**
* What TrekShellPlugin does for client certificates (TrekClientIdentity):
* check a server while noticing whether it asks for one, read a picked file,
* and ask for its password.
*/
object ClientCertActions {
private const val MAX_BODY_BYTES = 512 * 1024
private const val MAX_FILE_BYTES = 256 * 1024
private const val MAX_REDIRECTS = 5
/**
* A GET against the user's server for the address screen: status, body, final
* URL and headers, plus whether the server asked for a client certificate and
* whether one was shown. A failed connection answers status 0 instead of
* throwing, so a handshake the server broke off for want of a certificate
* still reports that it asked.
*/
@JvmStatic
fun request(context: Context, url: String, timeoutMs: Int): JSObject {
var asked: Triple<String, Int, Boolean>? = null
val factory = TrekClientIdentity.socketFactory(context) { host, port, served -> asked = Triple(host, port, served) }
val result = JSObject()
var current = URL(url)
try {
for (hop in 0..MAX_REDIRECTS) {
val connection = current.openConnection() as HttpURLConnection
if (connection is HttpsURLConnection) connection.sslSocketFactory = factory
connection.instanceFollowRedirects = false
connection.connectTimeout = timeoutMs
connection.readTimeout = timeoutMs
connection.setRequestProperty("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
try {
val status = connection.responseCode
val location = connection.getHeaderField("Location")
if (status in 300..399 && location != null && hop < MAX_REDIRECTS) {
current = URL(current, location)
continue
}
val stream = if (status >= 400) connection.errorStream else connection.inputStream
result.put("status", status)
result.put("data", stream?.use { readCapped(it, MAX_BODY_BYTES) }?.toString(Charsets.UTF_8) ?: "")
result.put("url", current.toString())
val headers = JSObject()
for ((name, values) in connection.headerFields) if (name != null && values.isNotEmpty()) headers.put(name, values.first())
result.put("headers", headers)
} finally {
connection.disconnect()
}
break
}
} catch (e: IOException) {
result.put("status", 0)
result.put("data", "")
result.put("url", current.toString())
result.put("error", e.javaClass.simpleName)
}
val seen = asked
result.put("certRequested", seen != null)
result.put("certSent", seen?.third == true)
if (seen != null) result.put("certHost", "${seen.first}:${seen.second}")
return result
}
/** The bytes of a picked file, or null when it cannot be read or is far too big for a certificate. */
@JvmStatic
fun readFile(context: Context, uri: Uri): ByteArray? = try {
context.contentResolver.openInputStream(uri)?.use { readCapped(it, MAX_FILE_BYTES + 1) }?.takeIf { it.size <= MAX_FILE_BYTES }
} catch (e: Exception) {
null
}
/**
* The password prompt, with the UI's own words (`labels`: title, message,
* placeholder, ok, cancel, wrongPassword). `wrong` shows that the last try
* did not open the file. `done` gets the password, or null when cancelled.
*/
@JvmStatic
fun askPassword(activity: Activity, labels: JSObject, wrong: Boolean, done: (CharArray?) -> Unit) {
activity.runOnUiThread {
val input = EditText(activity).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
hint = labels.getString("placeholder") ?: ""
}
val frame = FrameLayout(activity).apply {
val pad = (20 * activity.resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, 0)
addView(input, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
}
val message = if (wrong) labels.getString("wrongPassword") else labels.getString("message")
var answered = false
val dialog = AlertDialog.Builder(activity)
.setTitle(labels.getString("title"))
.setMessage(message)
.setView(frame)
.setPositiveButton(labels.getString("ok") ?: "OK") { _, _ ->
answered = true
val text = input.text
val password = CharArray(text.length) { text[it] }
done(password)
}
.setNegativeButton(labels.getString("cancel") ?: "Cancel") { _, _ ->
answered = true
done(null)
}
.setOnDismissListener { if (!answered) done(null) }
.create()
dialog.show()
input.requestFocus()
}
}
/** The settings' view of the stored certificate. */
@JvmStatic
fun infoObject(info: TrekClientIdentity.Info): JSObject = JSObject().apply {
put("host", info.host)
put("port", info.port)
put("subject", info.subject)
put("issuer", info.issuer)
put("notAfter", info.notAfter)
}
private fun readCapped(stream: InputStream, max: Int): ByteArray {
val out = ByteArrayOutputStream()
val buffer = ByteArray(8192)
while (out.size() < max) {
val n = stream.read(buffer, 0, minOf(buffer.size, max - out.size()))
if (n < 0) break
out.write(buffer, 0, n)
}
return out.toByteArray()
}
}
@@ -0,0 +1,50 @@
package com.liketrek.trek.clientcert
import android.content.Context
import java.util.concurrent.atomic.AtomicLong
/**
* Notices when the user's server stops letting the app in for want of a
* client certificate: one removed, expired, or no longer trusted by the proxy.
*
* The page alone does not show it. The service worker serves TREK's start page
* from its cache, so the page loads and only its requests fail, and the UI
* reports a server it cannot reach with no way to add a certificate. So when
* the server asks for one, the app checks once natively whether TREK answers
* with what it holds. Only a check that fails at the certificate (the handshake
* broke off, or the proxy answered with its own 4xx) sends the app to the
* address screen, which says so and offers to add one. A server that merely
* asks and lets everyone in answers TREK's 200, and nothing happens.
*/
object ClientCertWatch {
private const val MIN_INTERVAL_MS = 60_000L
private const val FEATURES_PATH = "/api/health/features"
private const val TIMEOUT_MS = 8000
private val lastCheck = AtomicLong(0)
/** Called where the server asked for a certificate; `onRefused` runs at most once a minute, on a background thread. */
@JvmStatic
fun serverAsked(context: Context, serverUrl: String?, onRefused: Runnable) {
if (serverUrl == null) return
val now = System.currentTimeMillis()
val last = lastCheck.get()
if (now - last < MIN_INTERVAL_MS || !lastCheck.compareAndSet(last, now)) return
val appContext = context.applicationContext
Thread({
val result = ClientCertActions.request(appContext, serverUrl.trimEnd('/') + FEATURES_PATH, TIMEOUT_MS)
if (refused(result.optBoolean("certRequested"), result.optInt("status"))) onRefused.run()
}, "trek-client-cert-watch").start()
}
/** A new or removed certificate is worth a check of its own, whenever the last one ran. */
@JvmStatic
fun reset() {
lastCheck.set(0)
}
/** The server asked for a certificate and did not answer with TREK. */
@JvmStatic
fun refused(certRequested: Boolean, status: Int): Boolean = certRequested && (status == 0 || status in 400..499)
}
@@ -0,0 +1,261 @@
package com.liketrek.trek.clientcert
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.io.ByteArrayInputStream
import java.io.IOException
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.UnrecoverableKeyException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Date
import java.util.Locale
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509ExtendedKeyManager
/**
* The client certificate a user's TREK server asks for (mutual TLS): a proxy
* such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through only
* devices that show one. The app holds at most one, for one server.
*
* It comes in as a PKCS#12 file (.p12, .pfx) and its password. Neither is kept:
* the private key is sealed with an AES key of the Android keystore, which
* never leaves it, and stored with the certificate chain in the app's private
* preferences. In memory the key is an ordinary one, which TLS 1.2 and 1.3 and
* the WebView all take, whatever its kind; a key imported into the keystore
* itself would be refused by some of them for its padding or digest rules.
*
* Only the host and port it was imported for ever see it (`identityFor`), so a
* login page, a map tile provider or liketrek.com never learns of it.
*/
object TrekClientIdentity {
private const val PREFS = "trek_client_identity"
private const val WRAP_ALIAS = "trek-client-identity-wrap"
private const val KEYMANAGER_ALIAS = "trek-client-identity"
private const val GCM_TAG_BITS = 128
/** Why a file could not be taken. */
enum class Failure { PASSWORD, FORMAT, NO_KEY, EXPIRED, NOT_YET_VALID }
class ImportException(val reason: Failure) : Exception(reason.name)
class Identity(val key: PrivateKey, val chain: Array<X509Certificate>)
/** What the settings show: who the certificate names, who issued it, until when, and for which server. */
data class Info(val host: String, val port: Int, val subject: String, val issuer: String, val notAfter: Long)
/** The opened identity, with the sealed key text it was opened from: opened once, not per request. */
@Volatile private var cached: Pair<String, Identity>? = null
/** The last time a server asked for a certificate while the app showed it: for the address screen. */
@Volatile var lastRequest: Request? = null
private set
data class Request(val host: String, val port: Int, val served: Boolean)
private class Stored(val host: String, val port: Int, val algorithm: String, val sealedText: String, val iv: ByteArray, val chain: List<ByteArray>)
/**
* Reads a PKCS#12 file: the first entry that holds a private key, with its chain.
* Throws ImportException naming what is wrong with it.
*/
@JvmStatic
@JvmOverloads
@Throws(ImportException::class)
fun read(bytes: ByteArray, password: CharArray, now: Date = Date()): Identity {
val store = KeyStore.getInstance("PKCS12")
try {
store.load(ByteArrayInputStream(bytes), password)
} catch (e: IOException) {
throw ImportException(if (isPasswordFailure(e)) Failure.PASSWORD else Failure.FORMAT)
} catch (e: Exception) {
throw ImportException(Failure.FORMAT)
}
val alias = store.aliases().toList().firstOrNull { store.isKeyEntry(it) } ?: throw ImportException(Failure.NO_KEY)
val key = try {
store.getKey(alias, password) as? PrivateKey
} catch (e: UnrecoverableKeyException) {
throw ImportException(Failure.PASSWORD)
} ?: throw ImportException(Failure.NO_KEY)
val chain = store.getCertificateChain(alias)?.mapNotNull { it as? X509Certificate }?.toTypedArray()
if (chain.isNullOrEmpty()) throw ImportException(Failure.NO_KEY)
val leaf = chain[0]
if (now.after(leaf.notAfter)) throw ImportException(Failure.EXPIRED)
if (now.before(leaf.notBefore)) throw ImportException(Failure.NOT_YET_VALID)
return Identity(key, chain)
}
/** A wrong password shows up as a failed MAC check or an unrecoverable key, wrapped in an IOException. */
private fun isPasswordFailure(e: Throwable): Boolean {
var cause: Throwable? = e
while (cause != null) {
if (cause is UnrecoverableKeyException || cause is javax.crypto.BadPaddingException) return true
if (cause.message?.lowercase(Locale.ROOT)?.contains("password") == true) return true
cause = cause.cause
}
return false
}
/** Keeps the identity for this host and port, replacing any other. */
@JvmStatic
fun save(context: Context, identity: Identity, host: String, port: Int) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, wrapKey())
val sealed = cipher.doFinal(identity.key.encoded)
prefs(context).edit()
.putString("host", host.lowercase(Locale.ROOT))
.putInt("port", port)
.putString("algorithm", identity.key.algorithm)
.putString("key", encode(sealed))
.putString("iv", encode(cipher.iv))
.putString("chain", identity.chain.joinToString(",") { encode(it.encoded) })
.apply()
cached = null
}
@JvmStatic
fun remove(context: Context) {
prefs(context).edit().clear().apply()
cached = null
}
@JvmStatic
fun info(context: Context): Info? {
val stored = stored(context) ?: return null
val leaf = runCatching { certificate(stored.chain.first()) }.getOrNull() ?: return null
return Info(stored.host, stored.port, commonName(leaf.subjectX500Principal), commonName(leaf.issuerX500Principal), leaf.notAfter.time)
}
/** The identity for a server at this host and port, or null when there is none for it. */
@JvmStatic
fun identityFor(context: Context, host: String?, port: Int): Identity? {
val stored = stored(context) ?: return null
if (!matches(stored.host, stored.port, host, port)) return null
return opened(stored)
}
private fun opened(stored: Stored): Identity? {
cached?.let { (from, identity) -> if (from == stored.sealedText) return identity }
val identity = runCatching { open(stored) }.getOrNull() ?: return null
cached = stored.sealedText to identity
return identity
}
/** Whether a request to host and port (-1 or 0 for the scheme's default) goes to the server the identity is for. */
@JvmStatic
fun matches(storedHost: String, storedPort: Int, host: String?, port: Int): Boolean {
if (host == null || !host.equals(storedHost, ignoreCase = true)) return false
val effective = if (port <= 0) 443 else port
return effective == storedPort
}
/** Called where a server asked for a certificate, so the address screen can offer one. */
@JvmStatic
fun noteRequest(host: String?, port: Int, served: Boolean) {
if (host == null) return
lastRequest = Request(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, served)
}
/**
* A socket factory for the app's own requests (the server check, map tiles).
* It offers the identity only when the server it is talking to asks for one
* and is the one the identity is for, and tells `onAsked` about every ask.
*/
@JvmStatic
fun socketFactory(context: Context, onAsked: ((host: String, port: Int, served: Boolean) -> Unit)? = null): SSLSocketFactory {
val appContext = context.applicationContext
val manager = object : X509ExtendedKeyManager() {
// Decided per handshake in choose(); the key and chain are then read for
// the one alias, so parallel connections never see each other's choice.
private fun choose(host: String?, port: Int): String? {
val identity = identityFor(appContext, host, port)
if (host != null) {
noteRequest(host, port, identity != null)
onAsked?.invoke(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, identity != null)
}
return if (identity != null) KEYMANAGER_ALIAS else null
}
override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? {
val session = (socket as? SSLSocket)?.handshakeSession
return choose(session?.peerHost, session?.peerPort ?: -1)
}
override fun chooseEngineClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, engine: SSLEngine?): String? =
choose(engine?.peerHost, engine?.peerPort ?: -1)
override fun getCertificateChain(alias: String?): Array<X509Certificate>? =
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.chain else null
override fun getPrivateKey(alias: String?): PrivateKey? =
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.key else null
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String? = null
}
val tls = SSLContext.getInstance("TLS")
tls.init(arrayOf(manager), null, null)
return tls.socketFactory
}
private fun open(stored: Stored): Identity {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), GCMParameterSpec(GCM_TAG_BITS, stored.iv))
val key = KeyFactory.getInstance(stored.algorithm).generatePrivate(PKCS8EncodedKeySpec(cipher.doFinal(decode(stored.sealedText))))
return Identity(key, stored.chain.map { certificate(it) }.toTypedArray())
}
private fun stored(context: Context): Stored? {
val p = prefs(context)
val host = p.getString("host", null) ?: return null
val algorithm = p.getString("algorithm", null) ?: return null
val key = p.getString("key", null) ?: return null
val iv = p.getString("iv", null) ?: return null
val chain = p.getString("chain", null)?.split(",")?.filter { it.isNotEmpty() }?.map { decode(it) } ?: return null
if (chain.isEmpty()) return null
return Stored(host, p.getInt("port", 443), algorithm, key, decode(iv), chain)
}
/** The keystore's AES key that seals the private key; made once, never leaves the keystore. */
private fun wrapKey(): SecretKey {
val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keystore.getKey(WRAP_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(
KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
return generator.generateKey()
}
private fun certificate(der: ByteArray): X509Certificate =
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(der)) as X509Certificate
/** The CN of a name, or the whole name when it has none. */
@JvmStatic
fun commonName(principal: javax.security.auth.x500.X500Principal): String {
val name = principal.getName(javax.security.auth.x500.X500Principal.RFC2253)
return Regex("(?:^|,)CN=((?:\\\\.|[^,])*)").find(name)?.groupValues?.get(1)?.replace("\\", "") ?: name
}
private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun encode(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.NO_WRAP)
private fun decode(text: String) = Base64.decode(text, Base64.NO_WRAP)
}
@@ -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)
+6
View File
@@ -2,9 +2,15 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
include ':capacitor-action-sheet'
project(':capacitor-action-sheet').projectDir = new File('../../node_modules/@capacitor/action-sheet/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android')
include ':capacitor-dialog'
project(':capacitor-dialog').projectDir = new File('../../node_modules/@capacitor/dialog/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android')
@@ -51,6 +51,7 @@
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; };
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */; };
896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
@@ -172,6 +173,7 @@
10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; };
114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; };
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekClientIdentity.swift; sourceTree = "<group>"; };
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -420,6 +422,7 @@
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */,
);
path = App;
sourceTree = "<group>";
@@ -823,6 +826,7 @@
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+19
View File
@@ -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])
@@ -60,6 +68,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
// MARK: - Hardware keyboard
/// The UI's shortcuts for the screen on show (TrekKeyCommands), in the app's
/// menu: the overlay a held Command key brings up lists them there.
override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
guard builder.system == .main, !TrekKeyCommands.current.isEmpty else { return }
let parent: UIMenu.Identifier = builder.menu(for: .application) != nil ? .application : .root
builder.insertChild(TrekKeyCommands.menu(), atStartOfMenu: parent)
}
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+6
View File
@@ -120,6 +120,10 @@
</array>
</dict>
</array>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -160,6 +164,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
+73
View File
@@ -6,6 +6,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
// Launched by a quick action on the app icon: held until the UI asks for it.
if let item = connectionOptions.shortcutItem { _ = TrekQuickActions.open(item) }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = TrekBridgeViewController()
@@ -45,6 +47,72 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
}
// A quick action on the app icon while the app was running in the background.
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void) {
completionHandler(TrekQuickActions.open(shortcutItem))
}
}
/// The quick actions on the app icon (a long press on it): "New trip" and the
/// trip coming up, set by the UI in its language (TrekShell.setQuickActions).
/// Each carries the path it opens; a tap goes to the UI as that path, at once
/// when it listens, or held for it to take when the tap launched the app.
enum TrekQuickActions {
private static let pathKey = "path"
private static var pending: String?
static var onOpen: ((String) -> Void)?
struct Item {
let type: String
let title: String
let subtitle: String?
let icon: String
let path: String
init?(raw: JSObject) {
guard let type = raw["type"] as? String, let title = raw["title"] as? String,
let path = raw["path"] as? String, path.hasPrefix("/") else { return nil }
self.type = type
self.title = title
self.subtitle = raw["subtitle"] as? String
self.icon = raw["icon"] as? String ?? ""
self.path = path
}
}
static func set(_ items: [Item]) {
UIApplication.shared.shortcutItems = items.prefix(4).map { item in
UIApplicationShortcutItem(
type: "com.liketrek.trek.\(item.type)",
localizedTitle: item.title,
localizedSubtitle: item.subtitle,
icon: icon(item.icon),
userInfo: [pathKey: item.path as NSString]
)
}
}
private static func icon(_ name: String) -> UIApplicationShortcutIcon {
switch name {
case "add": return UIApplicationShortcutIcon(type: .add)
case "trip": return UIApplicationShortcutIcon(systemImageName: "suitcase")
default: return UIApplicationShortcutIcon(type: .compose)
}
}
static func open(_ item: UIApplicationShortcutItem) -> Bool {
guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false }
if let onOpen { onOpen(path) } else { pending = path }
return true
}
/// The action the app was launched with, once.
static func take() -> String? {
defer { pending = nil }
return pending
}
}
/// The blur over the app while it sits in the app switcher, when the app lock
@@ -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)
}
@@ -43,12 +43,113 @@ class TrekBridgeViewController: CAPBridgeViewController {
navigationProxy = proxy
webView.navigationDelegate = proxy
}
guardBridgeMessages()
bridge?.registerPluginInstance(TrekShellPlugin())
// WebKit's own swipe back slides away a screenshot of the whole page,
// closes no sheet and takes every drag near the edge from the UI. The UI
// draws its own (mobile/components/MEdgeBack.tsx, feature "edgeBack"):
// the page follows the finger and an open sheet closes first.
webView?.allowsBackForwardNavigationGestures = false
// A long press on a link would lift a preview of the page it points to,
// which no native list does; the press means "drag" or nothing here.
webView?.allowsLinkPreview = false
}
/// Kept alive here: the content controller holds it, but only for as long as it is registered.
private var messageGuard: TrekBridgeMessageGuard?
/// Capacitor answers bridge messages from any page in the web view. With a
/// proxy's sign-in page loading there, its messages are dropped before they
/// reach the plugins. Always in place: a sign-in host can also be learned
/// while the app runs (TrekSignInRedirect).
private func guardBridgeMessages() {
guard let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
let controller = webView?.configuration.userContentController else { return }
let guarded = TrekBridgeMessageGuard(inner: handler)
controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name)
controller.add(guarded, name: TrekBridgeMessageGuard.name)
messageGuard = guarded
}
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
/// in the main menu, which sends their action up the responder chain from
/// the web view, and this controller is the first stop that answers it.
@objc func trekKeyCommand(_ sender: UIKeyCommand) {
guard let id = sender.propertyList as? String else { return }
TrekKeyCommands.onPress?(id)
}
}
// MARK: - Hardware keyboard (TrekShell.setKeyCommands)
/// The UI's keyboard shortcuts as key commands in the app's main menu. There
/// they show in the overlay a held Command key brings up and, on iPadOS 26, in
/// the menu bar, under the names the UI gives them in its language. Each screen
/// replaces the list (mobile/keyboard/MKeyboardHost.tsx), and the menu is
/// rebuilt with it, so only what works here and now is ever offered.
enum TrekKeyCommands {
struct Command {
let id: String
let title: String
let input: String
let modifiers: UIKeyModifierFlags
init?(raw: JSObject) {
guard let id = raw["id"] as? String, let title = raw["title"] as? String,
let input = raw["input"] as? String, !input.isEmpty else { return nil }
var flags: UIKeyModifierFlags = []
for name in raw["modifiers"] as? [String] ?? [] {
switch name {
case "command": flags.insert(.command)
case "shift": flags.insert(.shift)
case "option": flags.insert(.alternate)
case "control": flags.insert(.control)
default: break
}
}
self.id = id
self.title = title
self.input = Command.key(input)
self.modifiers = flags
}
private static func key(_ name: String) -> String {
switch name {
case "left": return UIKeyCommand.inputLeftArrow
case "right": return UIKeyCommand.inputRightArrow
case "up": return UIKeyCommand.inputUpArrow
case "down": return UIKeyCommand.inputDownArrow
case "escape": return UIKeyCommand.inputEscape
default: return name
}
}
}
static let menuIdentifier = UIMenu.Identifier("com.liketrek.trek.shortcuts")
private(set) static var current: [Command] = []
static var onPress: ((String) -> Void)?
static func set(_ commands: [Command]) {
current = commands
UIMenuSystem.main.setNeedsRebuild()
}
/// The commands as one menu, without a title of its own: it joins the app's
/// menu, where iPadOS lists the shortcuts under the app's name.
static func menu() -> UIMenu {
let children = current.map { command -> UIKeyCommand in
let key = UIKeyCommand(
title: command.title,
action: #selector(TrekBridgeViewController.trekKeyCommand(_:)),
input: command.input,
modifierFlags: command.modifiers,
propertyList: command.id
)
// ⌘[ and ⌘, belong to the app here, not to the web view's editing.
key.wantsPriorityOverSystemBehavior = true
return key
}
return UIMenu(title: "", identifier: menuIdentifier, options: .displayInline, children: children)
}
}
@@ -56,14 +157,63 @@ class TrekBridgeViewController: CAPBridgeViewController {
enum TrekServerStore {
private static let key = "TrekServerURL"
private static let signInHostsKey = "TrekServerSignInHosts"
/// Setting the server drops the sign-in hosts of the one before it.
static var url: String? {
get { UserDefaults.standard.string(forKey: key) }
set {
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) }
UserDefaults.standard.removeObject(forKey: signInHostsKey)
try? FileManager.default.removeItem(at: offlineCopyURL)
}
}
/// The hosts of a sign-in page in front of the server (a proxy such as
/// Cloudflare Access or Pangolin), as the address screen found them. Only
/// these load in the web view besides the server, so the proxy's session
/// cookie is the web view's (TrekNavigationProxy), and none of them may
/// talk to the app (TrekBridgeMessageGuard).
static var signInHosts: Set<String> {
get { Set((UserDefaults.standard.stringArray(forKey: signInHostsKey) ?? []).map { $0.lowercased() }) }
set {
if newValue.isEmpty {
UserDefaults.standard.removeObject(forKey: signInHostsKey)
} else {
UserDefaults.standard.set(Array(newValue).sorted(), forKey: signInHostsKey)
}
}
}
/// Keeps one more sign-in host for the remembered server, as the web view met it (TrekSignInRedirect).
static func addSignInHost(_ host: String) {
var hosts = signInHosts
guard hosts.insert(host.lowercased()).inserted else { return }
signInHosts = hosts
}
/// Whether `url` is one of the sign-in hosts, matched on host or host:port.
static func isSignInHost(_ url: URL?) -> Bool {
guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false }
let hosts = signInHosts
if hosts.contains(host) { return true }
if let port = url.port { return hosts.contains("\(host):\(port)") }
return false
}
/// The one-time code of a TREK Cloud purchase (setServer's `welcome`). The
/// server's first page opens its welcome page with it once, which signs the
/// buyer in (TrekNavigationProxy). Kept in memory only, never on disk.
static var pendingWelcome: String?
/// The welcome page for the pending code, handed out once.
static func takeWelcomeURL() -> URL? {
guard let code = pendingWelcome, let server = url else { return nil }
pendingWelcome = nil
let base = server.hasSuffix("/") ? server : server + "/"
return URL(string: base + "api/managed/welcome#" + code)
}
static var offlineCopyURL: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return support.appendingPathComponent("offline-start-page.html")
@@ -285,12 +435,44 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
}
}
/// A sign-in page in front of the server that the address screen never saw.
/// A proxy can let the app's check through (an Authelia or Authentik rule that
/// bypasses /api) and still send the page itself to its sign-in. Handed to
/// Safari, that sign-in leaves its session there and the app stays blank on
/// every launch. So a redirect away from the server, while the server's first
/// page is still loading, is taken as the proxy's sign-in: it loads in the web
/// view and its host is kept, the way the address screen keeps the ones it saw.
/// A link the user taps is no redirect, and once a page of the server has
/// shown, nothing is learned any more.
enum TrekSignInRedirect {
/// The host (host:port with an explicit port) to keep as a sign-in host, or nil.
static func learnedHost(server: String?, target: URL?, mainFrame: Bool, redirect: Bool, serverShown: Bool) -> String? {
guard mainFrame, redirect, !serverShown,
let server, let from = URL(string: server), let fromHost = from.host?.lowercased(),
let target, let toHost = target.host?.lowercased(),
let toScheme = target.scheme?.lowercased(), ["https", "http"].contains(toScheme) else { return nil }
// Never from https down to plain http: the sign-in would send a password in the clear.
if from.scheme?.lowercased() == "https", toScheme != "https" { return nil }
guard toHost != fromHost else { return nil }
if let port = target.port { return "\(toHost):\(port)" }
return toHost
}
/// Whether `url` is on the server's host, where the app's own pages come from.
static func onServer(server: String?, url: URL?) -> Bool {
guard let server, let host = URL(string: server)?.host?.lowercased() else { return false }
return url?.host?.lowercased() == host
}
}
/// Stands in front of Capacitor's navigation delegate and hands it everything
/// but the failures of a navigation that another one replaced: a cancelled
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
/// are no fault, and Capacitor would answer them with its error page.
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
private let inner: WKNavigationDelegate
/// A page of the server has shown since launch; from then on no sign-in host is learned (TrekSignInRedirect).
private var serverShown = false
init(inner: WKNavigationDelegate) {
self.inner = inner
@@ -310,6 +492,88 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
(inner as AnyObject).responds(to: aSelector) ? inner : nil
}
/// The sign-in page of a proxy in front of the server loads here instead of
/// in Safari, so its session is the web view's and every request carries it.
/// Only the hosts the address screen saw (TrekServerStore.signInHosts), and
/// only in the main frame; everything else is Capacitor's decision as before.
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.targetFrame?.isMainFrame == true, TrekServerStore.isSignInHost(navigationAction.request.url) {
decisionHandler(.allow)
return
}
// WebKit asks again for every server redirect, as a navigation of type .other.
if let host = TrekSignInRedirect.learnedHost(
server: TrekServerStore.url,
target: navigationAction.request.url,
mainFrame: navigationAction.targetFrame?.isMainFrame == true,
redirect: navigationAction.navigationType == .other,
serverShown: serverShown
) {
TrekServerStore.addSignInHost(host)
decisionHandler(.allow)
return
}
let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:")
guard (inner as AnyObject).responds(to: selector) else {
decisionHandler(.allow)
return
}
inner.webView?(webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
}
/// After the server's first page, a pending welcome code opens the welcome
/// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
inner.webView?(webView, didFinish: navigation)
if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) { serverShown = true }
if let welcome = TrekServerStore.takeWelcomeURL() {
webView.load(URLRequest(url: welcome))
}
}
/// A proxy in front of the server asks for a client certificate (mutual TLS):
/// the stored one goes to the host and port it was imported for and nowhere
/// else (TrekClientIdentity). Every other challenge is Capacitor's as before.
func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
if TrekClientIdentity.answer(challenge, completionHandler) {
let space = challenge.protectionSpace
TrekClientCertWatch.serverAsked(host: space.host, port: space.port) { [weak self, weak webView] in
guard let self, let webView else { return }
// Capacitor's own answer to a failed load: the address screen (errorPath).
self.inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRejected))
}
return
}
let selector = NSSelectorFromString("webView:didReceiveAuthenticationChallenge:completionHandler:")
guard (inner as AnyObject).responds(to: selector) else {
completionHandler(.performDefaultHandling, nil)
return
}
inner.webView?(webView, didReceive: challenge, completionHandler: completionHandler)
}
/// A proxy that asked the page for a client certificate, got none and
/// answered with its own refusal (Cloudflare's 403, nginx's 400) sends the
/// app to the address screen, which says so and offers to add one, rather
/// than leaving the proxy's error page up.
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse,
http.statusCode >= 400, http.statusCode < 500, let asked = TrekClientIdentity.lastRequest, !asked.served,
let url = http.url, url.host?.lowercased() == asked.host, (url.port ?? 443) == asked.port {
decisionHandler(.cancel)
inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRequired))
return
}
let selector = NSSelectorFromString("webView:decidePolicyForNavigationResponse:decisionHandler:")
guard (inner as AnyObject).responds(to: selector) else {
decisionHandler(.allow)
return
}
inner.webView?(webView, decidePolicyFor: navigationResponse, decisionHandler: decisionHandler)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return }
inner.webView?(webView, didFail: navigation, withError: error)
@@ -320,3 +584,26 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
}
}
/// Stands in front of Capacitor's bridge message handler and drops what a
/// proxy's sign-in page posts: that page loads in the web view (see
/// TrekNavigationProxy), and Capacitor itself would hand its messages to the
/// plugins whatever page they come from.
final class TrekBridgeMessageGuard: NSObject, WKScriptMessageHandler {
static let name = "bridge"
private let inner: WKScriptMessageHandler
init(inner: WKScriptMessageHandler) {
self.inner = inner
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
let origin = message.frameInfo.securityOrigin
var components = URLComponents()
components.scheme = origin.protocol
components.host = origin.host
if origin.port != 0 { components.port = origin.port }
if TrekServerStore.isSignInHost(components.url) { return }
inner.userContentController(userContentController, didReceive: message)
}
}
+393
View File
@@ -0,0 +1,393 @@
import Foundation
import Security
import UIKit
import UniformTypeIdentifiers
/// The client certificate a user's TREK server asks for (mutual TLS): a proxy
/// such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through
/// only devices that show one. The app holds at most one, for one server.
///
/// It comes in as a PKCS#12 file (.p12, .pfx) and its password. The password is
/// never kept; the identity goes into the keychain, this device only, readable
/// after the first unlock (so a background refresh can show it too). Only the
/// host and port it was imported for ever see it (`identity(forHost:port:)`), so a
/// sign-in page, a map tile provider or liketrek.com never learns of it.
enum TrekClientIdentity {
private static let label = "trek-client-identity"
private static let hostKey = "TrekClientIdentityHost"
private static let portKey = "TrekClientIdentityPort"
enum Failure: String, Error {
case password, format, noKey = "no_key", expired, notYetValid = "not_yet_valid", store
}
/// The last server that asked for a certificate while the app ran, and whether it got one.
struct Request {
let host: String
let port: Int
let served: Bool
}
private static let lock = NSLock()
private static var _lastRequest: Request?
static var lastRequest: Request? {
lock.lock(); defer { lock.unlock() }
return _lastRequest
}
static func noteRequest(host: String, port: Int, served: Bool) {
lock.lock(); defer { lock.unlock() }
_lastRequest = Request(host: host.lowercased(), port: port > 0 ? port : 443, served: served)
}
// MARK: Reading a file
/// Opens a PKCS#12 file: the identity and the rest of its chain.
static func read(_ data: Data, password: String, now: Date = Date()) -> Result<(SecIdentity, [SecCertificate]), Failure> {
var items: CFArray?
let status = SecPKCS12Import(data as CFData, [kSecImportExportPassphrase as String: password] as CFDictionary, &items)
switch status {
case errSecSuccess: break
case errSecAuthFailed, errSecPkcs12VerifyFailure: return .failure(.password)
default: return .failure(.format)
}
guard let first = (items as? [[String: Any]])?.first,
let value = first[kSecImportItemIdentity as String] else { return .failure(.noKey) }
let identity = value as! SecIdentity
var leaf: SecCertificate?
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return .failure(.noKey) }
if let validity = validity(of: leaf) {
if now > validity.notAfter { return .failure(.expired) }
if now < validity.notBefore { return .failure(.notYetValid) }
}
let leafData = SecCertificateCopyData(leaf) as Data
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate] ?? []).filter {
SecCertificateCopyData($0) as Data != leafData
}
return .success((identity, chain))
}
// MARK: Keeping it
/// Keeps the identity for this host and port, replacing any other.
static func save(_ identity: SecIdentity, chain: [SecCertificate], host: String, port: Int) -> Bool {
remove()
let query: [String: Any] = [
kSecValueRef as String: identity,
kSecAttrLabel as String: label,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { return false }
// The chain's intermediates go along so a proxy that wants them gets them.
let encoded = chain.map { SecCertificateCopyData($0) as Data }
UserDefaults.standard.set(encoded, forKey: chainKey)
UserDefaults.standard.set(host.lowercased(), forKey: hostKey)
UserDefaults.standard.set(port, forKey: portKey)
return true
}
private static let chainKey = "TrekClientIdentityChain"
static func remove() {
TrekClientCertWatch.reset()
for itemClass in [kSecClassIdentity, kSecClassCertificate, kSecClassKey] {
SecItemDelete([kSecClass as String: itemClass, kSecAttrLabel as String: label] as CFDictionary)
}
UserDefaults.standard.removeObject(forKey: hostKey)
UserDefaults.standard.removeObject(forKey: portKey)
UserDefaults.standard.removeObject(forKey: chainKey)
}
private static func storedIdentity() -> SecIdentity? {
var item: CFTypeRef?
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: label,
kSecReturnRef as String: true,
]
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item else { return nil }
return (item as! SecIdentity)
}
/// Whether a request to host and port (0 or less for https' default) goes to the server the identity is for.
static func matches(host: String?, port: Int) -> Bool {
guard let host, let stored = UserDefaults.standard.string(forKey: hostKey) else { return false }
let storedPort = UserDefaults.standard.integer(forKey: portKey)
return host.lowercased() == stored && (port > 0 ? port : 443) == (storedPort > 0 ? storedPort : 443)
}
/// The credential for a server at host and port, or nil when the stored one is for another.
static func credential(forHost host: String, port: Int) -> URLCredential? {
guard matches(host: host, port: port), let identity = storedIdentity() else { return nil }
let chain = (UserDefaults.standard.array(forKey: chainKey) as? [Data] ?? []).compactMap {
SecCertificateCreateWithData(nil, $0 as CFData)
}
// Per session: a replaced certificate takes effect on the next connection.
return URLCredential(identity: identity, certificates: chain.isEmpty ? nil : chain, persistence: .forSession)
}
/// Answers a challenge if it asks for a client certificate; false leaves it to the caller.
static func answer(_ challenge: URLAuthenticationChallenge, _ completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Bool {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate else { return false }
let space = challenge.protectionSpace
let credential = credential(forHost: space.host, port: space.port)
noteRequest(host: space.host, port: space.port, served: credential != nil)
if let credential {
completion(.useCredential, credential)
} else {
// As a browser without a matching certificate: go on without one.
completion(.performDefaultHandling, nil)
}
return true
}
// MARK: What the settings show
static func info() -> [String: Any]? {
guard let host = UserDefaults.standard.string(forKey: hostKey), let identity = storedIdentity() else { return nil }
var leaf: SecCertificate?
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return nil }
let port = UserDefaults.standard.integer(forKey: portKey)
var result: [String: Any] = [
"host": host,
"port": port > 0 ? port : 443,
"subject": SecCertificateCopySubjectSummary(leaf) as String? ?? "",
"issuer": issuerName(of: leaf) ?? "",
]
if let validity = validity(of: leaf) { result["notAfter"] = Int(validity.notAfter.timeIntervalSince1970 * 1000) }
return result
}
// MARK: Certificate fields
/// notBefore and notAfter from the DER: iOS has no public API for them.
static func validity(of certificate: SecCertificate) -> (notBefore: Date, notAfter: Date)? {
DER.validity(SecCertificateCopyData(certificate) as Data)
}
private static func issuerName(of certificate: SecCertificate) -> String? {
DER.issuerCommonName(SecCertificateCopyData(certificate) as Data)
}
/// Just enough DER to read an X.509 certificate's validity and a name's CN.
enum DER {
private struct Node {
let tag: UInt8
let body: Range<Int>
let end: Int
}
private static func node(_ bytes: [UInt8], at offset: Int) -> Node? {
guard offset + 2 <= bytes.count else { return nil }
let tag = bytes[offset]
var length = Int(bytes[offset + 1])
var start = offset + 2
if length & 0x80 != 0 {
let count = length & 0x7f
guard count > 0, count <= 4, start + count <= bytes.count else { return nil }
length = bytes[start..<start + count].reduce(0) { $0 << 8 | Int($1) }
start += count
}
guard start + length <= bytes.count else { return nil }
return Node(tag: tag, body: start..<start + length, end: start + length)
}
private static func children(_ bytes: [UInt8], in range: Range<Int>) -> [Node] {
var result: [Node] = []
var offset = range.lowerBound
while offset < range.upperBound, let child = node(bytes, at: offset) {
result.append(child)
offset = child.end
}
return result
}
/// The fields of the TBSCertificate after the version: serial, signature algorithm, issuer, validity, ...
private static func tbsFields(_ bytes: [UInt8]) -> [Node]? {
guard let certificate = node(bytes, at: 0), let tbs = children(bytes, in: certificate.body).first else { return nil }
var fields = children(bytes, in: tbs.body)
if fields.first?.tag == 0xa0 { fields.removeFirst() }
return fields.count > 3 ? fields : nil
}
static func validity(_ data: Data) -> (notBefore: Date, notAfter: Date)? {
let bytes = [UInt8](data)
guard let fields = tbsFields(bytes) else { return nil }
let times = children(bytes, in: fields[3].body)
guard times.count == 2, let from = time(bytes, times[0]), let to = time(bytes, times[1]) else { return nil }
return (from, to)
}
private static func time(_ bytes: [UInt8], _ node: Node) -> Date? {
guard let text = String(bytes: bytes[node.body], encoding: .ascii) else { return nil }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.dateFormat = node.tag == 0x17 ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
return formatter.date(from: text)
}
/// The CN of the issuer's Name (a SEQUENCE of SETs of type and value), or nil.
static func issuerCommonName(_ data: Data) -> String? {
let bytes = [UInt8](data)
guard let fields = tbsFields(bytes) else { return nil }
for set in children(bytes, in: fields[2].body) {
for pair in children(bytes, in: set.body) {
let parts = children(bytes, in: pair.body)
guard parts.count == 2, Array(bytes[parts[0].body]) == [0x55, 0x04, 0x03] else { continue }
return String(bytes: bytes[parts[1].body], encoding: .utf8)
}
}
return nil
}
}
}
/// Notices when the user's server stops letting the app in for want of a
/// client certificate (one removed, expired, or no longer trusted). The page can
/// still be the stored offline copy while every request fails, so when the server
/// asks for a certificate the app checks once natively whether TREK answers with
/// what it holds. Only a check that fails at the certificate (the handshake broke
/// off, or the proxy's own 4xx) calls `onRefused`; a server that merely asks and
/// lets everyone in answers TREK's 200. At most once a minute.
enum TrekClientCertWatch {
private static let lock = NSLock()
private static var lastCheck = Date.distantPast
/// A new or removed certificate is worth a check of its own, whenever the last one ran.
static func reset() {
lock.lock(); defer { lock.unlock() }
lastCheck = .distantPast
}
static func serverAsked(host: String, port: Int, onRefused: @escaping () -> Void) {
guard let raw = TrekServerStore.url, let server = URL(string: raw), server.scheme == "https",
server.host?.lowercased() == host.lowercased(), (server.port ?? 443) == (port > 0 ? port : 443),
let features = URL(string: raw.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/health/features") else { return }
lock.lock()
let due = Date().timeIntervalSince(lastCheck) >= 60
if due { lastCheck = Date() }
lock.unlock()
guard due else { return }
var request = URLRequest(url: features, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
request.setValue("application/json", forHTTPHeaderField: "Accept")
var task: URLSessionDataTask?
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { _, response, _ in
let asked = task.flatMap { TrekClientCertSessionDelegate.shared.ask(for: $0) } != nil
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if asked && (status == 0 || (400..<500).contains(status)) {
DispatchQueue.main.async(execute: onRefused)
}
}
task?.resume()
}
}
/// A URLSession delegate for the app's own requests to the user's server: it
/// shows the client certificate where the server asks for it and notes the ask.
final class TrekClientCertSessionDelegate: NSObject, URLSessionTaskDelegate {
static let shared = TrekClientCertSessionDelegate()
/// The app's session for the user's server (offline copy, watch, address check).
static let session = URLSession(configuration: .default, delegate: shared, delegateQueue: nil)
/// Per task: whether the server asked, and whether it got a certificate (address check only).
private let lock = NSLock()
private var asks: [Int: Bool] = [:]
func ask(for task: URLSessionTask) -> Bool? {
lock.lock(); defer { lock.unlock() }
return asks.removeValue(forKey: task.taskIdentifier)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
let handled = TrekClientIdentity.answer(challenge) { disposition, credential in
self.lock.lock()
self.asks[task.taskIdentifier] = credential != nil
self.lock.unlock()
completionHandler(disposition, credential)
}
if !handled { completionHandler(.performDefaultHandling, nil) }
}
}
/// The file picker and password prompt of an import, in the UI's words.
final class TrekClientCertImporter: NSObject, UIDocumentPickerDelegate {
private let presenter: UIViewController
private let labels: [String: String]
private let host: String
private let port: Int
private let done: ([String: Any]) -> Void
private var keepAlive: TrekClientCertImporter?
init(presenter: UIViewController, labels: [String: String], host: String, port: Int, done: @escaping ([String: Any]) -> Void) {
self.presenter = presenter
self.labels = labels
self.host = host
self.port = port
self.done = done
}
func start() {
keepAlive = self
let types = [UTType(filenameExtension: "p12"), UTType(filenameExtension: "pfx"), UTType("com.rsa.pkcs-12")].compactMap { $0 }
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types.isEmpty ? [.data] : types, asCopy: true)
picker.delegate = self
presenter.present(picker, animated: true)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
finish(["result": "cancelled"])
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first,
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
(attributes[.size] as? Int ?? 0) <= 256 * 1024,
let data = try? Data(contentsOf: url) else {
finish(["result": "failed", "failure": TrekClientIdentity.Failure.format.rawValue])
return
}
try? FileManager.default.removeItem(at: url)
askPassword(for: data, wrong: false)
}
private func askPassword(for data: Data, wrong: Bool) {
let alert = UIAlertController(title: labels["title"], message: wrong ? labels["wrongPassword"] : labels["message"], preferredStyle: .alert)
alert.addTextField { field in
field.isSecureTextEntry = true
field.placeholder = self.labels["placeholder"]
field.textContentType = .password
}
alert.addAction(UIAlertAction(title: labels["cancel"] ?? "Cancel", style: .cancel) { _ in
self.finish(["result": "cancelled"])
})
alert.addAction(UIAlertAction(title: labels["ok"] ?? "OK", style: .default) { _ in
self.open(data, password: alert.textFields?.first?.text ?? "")
})
presenter.present(alert, animated: true)
}
private func open(_ data: Data, password: String) {
switch TrekClientIdentity.read(data, password: password) {
case .failure(.password):
askPassword(for: data, wrong: true)
case .failure(let failure):
finish(["result": "failed", "failure": failure.rawValue])
case .success(let (identity, chain)):
guard TrekClientIdentity.save(identity, chain: chain, host: host, port: port) else {
finish(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
return
}
var result: [String: Any] = ["result": "imported"]
if let info = TrekClientIdentity.info() { result["certificate"] = info }
finish(result)
}
}
private func finish(_ result: [String: Any]) {
done(result)
keepAlive = nil
}
}
+148 -4
View File
@@ -48,6 +48,13 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
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),
]
/// What this binary offers the server's UI, raised with every native feature
@@ -55,10 +62,19 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
static let shellApi = 2
/// The native features by name, so the UI can turn each on by itself.
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never builds a snapshot for nothing.
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never
/// 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] {
["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet"]
+ (TrekWatchLink.isAvailable ? ["watch"] : [])
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate"]
if canAddWalletPasses { names.append("wallet") }
if TrekWatchLink.isAvailable { names.append("watch") }
return names
}
/// Whether this device can add a pass to Apple Wallet (an iPhone; not an iPad).
static var canAddWalletPasses: Bool {
PKPassLibrary.isPassLibraryAvailable() && PKAddPassesViewController.canAddPasses()
}
// MARK: - Shared booking documents
@@ -72,6 +88,47 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
}
TrekStore.watchUpdates()
TrekWatchLink.shared.onAction = { [weak self] in self?.notifyListeners("watchAction", data: [:]) }
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
/// The UI's keyboard shortcuts for the screen it shows (mobile/keyboard),
/// named in its language, as key commands in the app's menu.
@objc func setKeyCommands(_ call: CAPPluginCall) {
let raw = call.getArray("commands", JSObject.self) ?? []
let commands = raw.compactMap { TrekKeyCommands.Command(raw: $0) }
DispatchQueue.main.async {
TrekKeyCommands.set(commands)
call.resolve()
}
}
// MARK: - Widgets and the travel-day Live Activity
@@ -298,6 +355,12 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
return
}
TrekServerStore.url = raw
// After the URL: setting it clears the hosts of the server before.
let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil }
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
let welcome = call.getString("welcome") ?? ""
TrekServerStore.pendingWelcome = welcome.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil ? welcome : nil
call.resolve()
restartBridge()
}
@@ -370,7 +433,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
}
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
request.setValue("text/html", forHTTPHeaderField: "Accept")
URLSession.shared.dataTask(with: request) { data, response, _ in
// The app's session, so a server behind mutual TLS gets the client certificate.
TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, _ in
guard let data, let http = response as? HTTPURLResponse, http.statusCode == 200,
http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/html") == true,
TrekServerStore.url == raw else {
@@ -409,6 +473,86 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
}
}
// MARK: - Client certificates (mutual TLS)
extension TrekShellPlugin {
/// The address screen's check of a server, made natively so it notices
/// whether the server asks for a client certificate and can show the stored
/// one. A failed connection resolves with status 0 instead of rejecting.
@objc func requestServer(_ call: CAPPluginCall) {
guard let raw = call.getString("url"), let url = URL(string: raw),
["https", "http"].contains(url.scheme?.lowercased() ?? ""), url.host != nil else {
call.reject("A http(s) URL is required")
return
}
let timeout = min(max(Double(call.getInt("timeoutMs") ?? 10000), 1000), 30000) / 1000
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
request.setValue("application/json, text/html;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept")
let delegate = TrekClientCertSessionDelegate.shared
var task: URLSessionDataTask?
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, error in
let asked = task.flatMap { delegate.ask(for: $0) }
var result: [String: Any] = [
"certRequested": asked != nil,
"certSent": asked == true,
"url": response?.url?.absoluteString ?? raw,
]
if asked != nil, let host = url.host?.lowercased() { result["certHost"] = "\(host):\(url.port ?? 443)" }
if let http = response as? HTTPURLResponse {
result["status"] = http.statusCode
let body = (data ?? Data()).prefix(512 * 1024)
result["data"] = String(data: body, encoding: .utf8) ?? ""
var headers: [String: String] = [:]
for (name, value) in http.allHeaderFields {
if let name = name as? String, let value = value as? String { headers[name] = value }
}
result["headers"] = headers
} else {
result["status"] = 0
result["data"] = ""
if let error { result["error"] = (error as NSError).domain + ":" + String((error as NSError).code) }
}
call.resolve(result)
}
task?.resume()
}
/// The stored client certificate's details, and the last server that asked for one.
@objc func clientCertificate(_ call: CAPPluginCall) {
var result: [String: Any] = [:]
if let info = TrekClientIdentity.info() { result["certificate"] = info } else { result["certificate"] = NSNull() }
if let asked = TrekClientIdentity.lastRequest {
result["request"] = ["host": asked.host, "port": asked.port, "served": asked.served]
}
call.resolve(result)
}
@objc func removeClientCertificate(_ call: CAPPluginCall) {
TrekClientIdentity.remove()
call.resolve()
}
/// Picks a .p12/.pfx file, asks for its password until it opens or the user
/// gives up, and keeps it for the server at host and port.
@objc func importClientCertificate(_ call: CAPPluginCall) {
guard let host = call.getString("host"), host.range(of: "^[A-Za-z0-9.-]+$", options: .regularExpression) != nil else {
call.reject("A host is required")
return
}
let port = call.getInt("port") ?? 443
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
DispatchQueue.main.async { [weak self] in
guard let presenter = self?.bridge?.viewController else {
call.resolve(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
return
}
TrekClientCertImporter(presenter: presenter, labels: labels, host: host, port: port) { result in
call.resolve(result)
}.start()
}
}
}
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
/// When-In-Use permission asked the first time.
// MARK: - Apple Wallet
+240 -2
View File
@@ -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
}
}
+4
View File
@@ -12,7 +12,9 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"),
.package(name: "CapacitorActionSheet", path: "../../../../node_modules/@capacitor/action-sheet"),
.package(name: "CapacitorApp", path: "../../../../node_modules/@capacitor/app"),
.package(name: "CapacitorDialog", path: "../../../../node_modules/@capacitor/dialog"),
.package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorHaptics", path: "../../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../../node_modules/@capacitor/keyboard"),
@@ -26,7 +28,9 @@ let package = Package(
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorActionSheet", package: "CapacitorActionSheet"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorDialog", package: "CapacitorDialog"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
@@ -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)
}
+35 -10
View File
@@ -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 {
+22 -11
View File
@@ -6,16 +6,24 @@ import SwiftUI
struct ShareView: View {
@ObservedObject var model: ShareModel
@Environment(\.colorScheme) private var scheme
@Environment(\.horizontalSizeClass) private var widthClass
@State private var shown = false
/// An iPad window of regular width: the card stands centred over the dimmed
/// app, as a form does there, instead of rising from the bottom edge. A
/// narrow Split View or Slide Over window keeps the iPhone's bottom card.
private var centred: Bool { widthClass == .regular }
var body: some View {
ZStack(alignment: .bottom) {
Color.black.opacity(shown ? 0.28 : 0)
ZStack(alignment: centred ? .center : .bottom) {
Color.black.opacity(shown ? (centred ? 0.36 : 0.28) : 0)
.ignoresSafeArea()
.onTapGesture { dismiss() }
card
.offset(y: shown ? 0 : 520)
.offset(y: shown ? 0 : (centred ? 40 : 520))
.opacity(centred && !shown ? 0 : 1)
.padding(.vertical, centred ? 24 : 0)
}
.onAppear {
withAnimation(.spring(response: 0.5, dampingFraction: 0.86)) { shown = true }
@@ -31,14 +39,17 @@ struct ShareView: View {
private var card: some View {
VStack(spacing: 0) {
Capsule()
.fill(ShareStyle.faint(scheme).opacity(0.5))
.frame(width: 38, height: 5)
.padding(.top, 8)
// The grabber belongs to a card that rises from the bottom edge.
if !centred {
Capsule()
.fill(ShareStyle.faint(scheme).opacity(0.5))
.frame(width: 38, height: 5)
.padding(.top, 8)
}
header
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.top, centred ? 22 : 14)
Group {
switch model.phase {
@@ -50,7 +61,7 @@ struct ShareView: View {
}
.padding(.top, 16)
}
.padding(.bottom, 12)
.padding(.bottom, centred ? 20 : 12)
.frame(maxWidth: 560)
.background(
RoundedRectangle(cornerRadius: 32, style: .continuous)
@@ -60,9 +71,9 @@ struct ShareView: View {
.strokeBorder(ShareStyle.edge(scheme), lineWidth: 1)
)
.shadow(color: .black.opacity(0.25), radius: 30, y: 10)
.ignoresSafeArea(edges: .bottom)
.ignoresSafeArea(edges: centred ? [] : .bottom)
)
.padding(.horizontal, 6)
.padding(.horizontal, centred ? 24 : 6)
}
private var header: some View {
@@ -9,6 +9,24 @@ import UIKit
final class ShareViewController: UIViewController {
private let model = ShareModel()
// On an iPad the system would put the extension in a form sheet, a box in the
// middle of the screen with this sheet's own dimming and card inside it. Over
// the full screen instead, the card stands centred over the app that shared,
// the way an iPad presents a small form.
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
presentOverFullScreenOnPad()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
presentOverFullScreenOnPad()
}
private func presentOverFullScreenOnPad() {
if UIDevice.current.userInterfaceIdiom == .pad { modalPresentationStyle = .overFullScreen }
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
+20 -2
View File
@@ -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).
+30 -13
View File
@@ -44,9 +44,16 @@ struct AtlasContent: View {
Text("\(Image(systemName: "globe.europe.africa.fill")) \(atlas?.count ?? 0) \(labels?.countries ?? "")")
case .small:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
// The word goes under the number where both do not fit on one line (an iPad mini).
ViewThatFits(in: .horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
}
VStack(alignment: .leading, spacing: -2) {
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
}
}
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
Spacer(minLength: 6)
@@ -71,18 +78,15 @@ struct AtlasContent: View {
}
default:
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .lastTextBaseline, spacing: 6) {
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
VStack(alignment: .leading, spacing: 0) {
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
}
Spacer(minLength: 4)
if let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
if let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
// The trips and days step aside before the share of the world is cut (an iPad mini).
ViewThatFits(in: .horizontal) {
largeHeader(atlas, labels, stats: true)
largeHeader(atlas, labels, stats: false)
}
// The map takes what the rows leave, so a smaller large widget (an
// iPad mini's) keeps the continents and the latest countries whole.
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
.frame(height: 150)
.frame(minHeight: 60, maxHeight: 150)
ContinentBars(continents: atlas?.continents ?? [], scheme: scheme)
HStack(spacing: 6) {
ForEach(Array((atlas?.recent ?? []).prefix(3).enumerated()), id: \.offset) { index, country in
@@ -94,6 +98,19 @@ struct AtlasContent: View {
}
}
private func largeHeader(_ atlas: WidgetSnapshot.Atlas?, _ labels: WidgetSnapshot.Labels?, stats: Bool) -> some View {
HStack(alignment: .lastTextBaseline, spacing: 6) {
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
VStack(alignment: .leading, spacing: 0) {
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
}
Spacer(minLength: 4)
if stats, let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
if stats, let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
}
}
private var ink: Color { Trek.ink(scheme) }
private var muted: Color { Trek.muted(scheme) }
private var glow: [Color] { [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)] }
@@ -29,6 +29,7 @@ struct BookingsContent: View {
switch family {
case .small: hero(first, labels)
case .medium: list(Array(bookings.prefix(2)), labels, featureFirst: true)
case .extraLarge: wide(bookings, labels)
default: list(Array(bookings.prefix(5)), labels, featureFirst: true)
}
} else {
@@ -74,7 +75,8 @@ struct BookingsContent: View {
// MARK: Medium and large: a list, the first one featured
private func list(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels, featureFirst: Bool) -> some View {
VStack(alignment: .leading, spacing: family == .large ? 8 : 7) {
// The extra large's list stands beside a card as tall as the widget: its rows space out to match.
VStack(alignment: .leading, spacing: family == .extraLarge ? 14 : family.isTall ? 8 : 7) {
HStack {
Text(labels.bookings ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
@@ -89,7 +91,33 @@ struct BookingsContent: View {
}
}
}
if family == .large { Spacer(minLength: 0) }
if family.isTall { Spacer(minLength: 0) }
}
}
// MARK: Extra large (iPad): the next one as a card, the rest beside it
private func wide(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels) -> some View {
let first = bookings[0]
let look = Trek.booking(first.kind)
let rest = Array(bookings.dropFirst().prefix(5))
return HStack(alignment: .top, spacing: 18) {
RowLink(path: first.path) {
hero(first, labels)
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(LinearGradient(colors: [look.color.opacity(scheme == .dark ? 0.24 : 0.14), look.color.opacity(scheme == .dark ? 0.08 : 0.04)], startPoint: .topLeading, endPoint: .bottomTrailing))
)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(look.color.opacity(0.25), lineWidth: 0.8))
}
.frame(maxWidth: rest.isEmpty ? CGFloat.infinity : 240)
if !rest.isEmpty {
list(rest, labels, featureFirst: false)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
}
@@ -51,6 +51,7 @@ extension TrekFamily {
switch family {
case .systemSmall: self = .small
case .systemMedium: self = .medium
case .systemExtraLarge: self = .extraLarge
case .accessoryCircular: self = .circular
case .accessoryRectangular: self = .rectangular
case .accessoryInline: self = .inline
@@ -92,7 +93,8 @@ struct NextTripWidget: Widget {
}
.configurationDisplayName("widget.trip.name")
.description("widget.trip.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
// systemExtraLarge is the iPad's; an iPhone leaves it out of its gallery.
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
@@ -123,7 +125,7 @@ struct TodayWidget: Widget {
}
.configurationDisplayName("widget.today.name")
.description("widget.today.description")
.supportedFamilies([.systemMedium, .systemLarge])
.supportedFamilies([.systemMedium, .systemLarge, .systemExtraLarge])
}
}
@@ -146,7 +148,7 @@ struct BookingsWidget: Widget {
}
.configurationDisplayName("widget.bookings.name")
.description("widget.bookings.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryInline])
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryRectangular, .accessoryInline])
}
}
+151 -36
View File
@@ -14,55 +14,49 @@ struct TodayContent: View {
let total = today.total ?? snapshot.tripLength ?? 1
let timed = today.stops.filter { $0.minutes != nil }
let done = timed.filter { ($0.minutes ?? 0) < nowMinutes }.count
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center, spacing: 10) {
DayBadge(index: today.index ?? 1, total: total)
VStack(alignment: .leading, spacing: 1) {
Text(trip.title).trekEyebrow(scheme)
Text(today.dayLabel)
.font(.system(size: family == .large ? 19 : 16, weight: .heavy))
.foregroundStyle(Trek.ink(scheme))
.lineLimit(1)
if family == .extraLarge {
wide(snapshot, trip, today, total: total, done: done, timed: timed.count, nowMinutes: nowMinutes)
} else {
VStack(alignment: .leading, spacing: 0) {
// On a narrow widget (an iPad mini's) the weather shrinks to its
// chip, then steps aside, before the trip's name is cut short.
ViewThatFits(in: .horizontal) {
header(trip, today, total: total, weather: family == .large ? .full : .chip)
header(trip, today, total: total, weather: .chip)
header(trip, today, total: total, weather: .hidden)
}
Spacer(minLength: 0)
if let weather = snapshot.weather {
if family == .large {
PaperWeather(weather: weather)
} else {
WeatherChip(weather: weather)
if family == .large {
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
.padding(.top, 12)
.padding(.bottom, 14)
} else {
Spacer(minLength: 8)
}
StopList(
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
)
if family == .large {
Spacer(minLength: 8)
if let booking = snapshot.bookings?.first {
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
}
}
}
if family == .large {
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
.padding(.top, 12)
.padding(.bottom, 14)
} else {
Spacer(minLength: 8)
}
StopList(
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
)
if family == .large {
Spacer(minLength: 8)
if let booking = snapshot.bookings?.first {
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
}
}
}
} else if let snapshot, let trip = snapshot.trip {
// Before the trip there is no day yet: count down to it instead.
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
let roomy = family == .extraLarge
VStack(alignment: .leading, spacing: 4) {
Text(snapshot.labels.nextTrip).trekEyebrow(scheme)
Text(trip.title).font(.system(size: 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
Text(trip.title).font(.system(size: roomy ? 24 : 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
Spacer(minLength: 0)
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(days)").font(Trek.number(40, .heavy)).foregroundStyle(Trek.ink(scheme))
Text(snapshot.labels.days ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
Text("\(days)").font(Trek.number(roomy ? 56 : 40, .heavy)).foregroundStyle(Trek.ink(scheme))
Text(snapshot.labels.days ?? "").font(.system(size: roomy ? 16 : 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
}
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: 24)
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: roomy ? 34 : 24)
}
} else {
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
@@ -70,6 +64,66 @@ struct TodayContent: View {
}
}
extension TodayContent {
enum HeaderWeather { case full, chip, hidden }
/// The day's badge, the trip and the day, and the weather as much as fits.
func header(_ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today, total: Int, weather: HeaderWeather) -> some View {
HStack(alignment: .center, spacing: 10) {
DayBadge(index: today.index ?? 1, total: total)
VStack(alignment: .leading, spacing: 1) {
Text(trip.title).trekEyebrow(scheme)
Text(today.dayLabel)
.font(.system(size: family == .medium ? 16 : 19, weight: .heavy))
.foregroundStyle(Trek.ink(scheme))
.lineLimit(1)
}
Spacer(minLength: 0)
if let forecast = snapshot?.weather {
switch weather {
case .full: PaperWeather(weather: forecast)
case .chip: WeatherChip(weather: forecast)
case .hidden: EmptyView()
}
}
}
}
/// The iPad's extra large: the day, its progress and the weather beside
/// the day's timeline, and the next two bookings across the foot.
func wide(
_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today,
total: Int, done: Int, timed: Int, nowMinutes: Int
) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 22) {
VStack(alignment: .leading, spacing: 0) {
header(trip, today, total: total, weather: .hidden)
DayProgress(done: done, total: timed, scheme: scheme, label: snapshot.labels.allDone)
.padding(.top, 12)
if let weather = snapshot.weather {
PaperWeather(weather: weather).padding(.top, 16)
}
}
.frame(width: 250)
StopList(
stops: today.stops, nowMinutes: nowMinutes, limit: 6,
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
Spacer(minLength: 10)
if let bookings = snapshot.bookings, !bookings.isEmpty {
HStack(spacing: 12) {
ForEach(Array(bookings.prefix(2).enumerated()), id: \.offset) { _, booking in
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
}
}
}
}
}
}
/// Today's number in a dark tile with the trip's length under it.
struct DayBadge: View {
let index: Int
@@ -142,3 +196,64 @@ struct DayProgress: View {
}
}
}
/// The travel day as its Live Activity shows it on the lock screen (iPhone and
/// iPad): trip and day on top, the next stop large, the day's stops as
/// segments. Plain values rather than the activity's attributes, so the
/// preview renderer can draw it at every width the lock screen gives it.
struct TripDayBanner: View {
let tripTitle: String
let dayLabel: String
let nextName: String
let nextTime: String?
let done: Int
let total: Int
var body: some View {
HStack(alignment: .center, spacing: 14) {
ZStack {
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
Circle()
.trim(from: 0, to: total > 0 ? max(0.02, Double(done) / Double(total)) : 0)
.stroke(
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
style: StrokeStyle(lineWidth: 5, lineCap: .round)
)
.rotationEffect(.degrees(-90))
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
}
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(tripTitle)
.font(.system(size: 11, weight: .bold))
.tracking(0.5)
.textCase(.uppercase)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
Spacer(minLength: 4)
Text(dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
}
HStack(alignment: .firstTextBaseline) {
Text(nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1).minimumScaleFactor(0.8)
Spacer(minLength: 4)
if let nextTime {
Text(nextTime).font(Trek.number(18, .heavy)).foregroundStyle(.white)
}
}
DayStopProgress(done: done, total: total).padding(.top, 3)
}
}
.padding(16)
}
}
/// The day's timed stops as segments, those behind filled (the Live Activity's bar).
struct DayStopProgress: View {
let done: Int
let total: Int
var body: some View {
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
}
}
+36 -2
View File
@@ -4,16 +4,22 @@ import Foundation
/// so the views also render outside a widget (the preview renderer in
/// client/ios/WidgetPreviews draws every size to a PNG on the CI's Mac).
enum TrekFamily: String, CaseIterable {
case small, medium, large, circular, rectangular, inline
case small, medium, large, extraLarge, circular, rectangular, inline
var isAccessory: Bool { self == .circular || self == .rectangular || self == .inline }
/// The point size of the family on a 6.1" iPhone, for the renderer.
/// A home-screen widget as tall as a large one: the large and the iPad's
/// extra large, which have room for a list with a foot under it.
var isTall: Bool { self == .large || self == .extraLarge }
/// The point size of the family on a 6.1" iPhone, for the renderer. The
/// extra large exists on an iPad only; its size is that of an 11" iPad.
var previewSize: CGSize {
switch self {
case .small: return CGSize(width: 170, height: 170)
case .medium: return CGSize(width: 364, height: 170)
case .large: return CGSize(width: 364, height: 382)
case .extraLarge: return CGSize(width: 715.5, height: 342)
case .circular: return CGSize(width: 76, height: 76)
case .rectangular: return CGSize(width: 172, height: 76)
case .inline: return CGSize(width: 250, height: 26)
@@ -21,6 +27,34 @@ enum TrekFamily: String, CaseIterable {
}
}
/// The iPads the renderer draws the home-screen widgets for, with the sizes
/// iPadOS gives each family there (Human Interface Guidelines, Widgets). An
/// iPad mini's small widget is smaller than an iPhone's, a 13" iPad's medium
/// and large are wider.
enum TrekPad: String, CaseIterable {
case ipad13, ipad11, ipadMini
/// The small widget's side, the medium's and large's width, the extra large's width.
private var metrics: (small: CGFloat, wide: CGFloat, extraWide: CGFloat) {
switch self {
case .ipad13: return (170, 378.5, 795)
case .ipad11: return (155, 342, 715.5)
case .ipadMini: return (141, 305.5, 634.5)
}
}
func size(_ family: TrekFamily) -> CGSize {
let m = metrics
switch family {
case .small: return CGSize(width: m.small, height: m.small)
case .medium: return CGSize(width: m.wide, height: m.small)
case .large: return CGSize(width: m.wide, height: m.wide)
case .extraLarge: return CGSize(width: m.extraWide, height: m.wide)
default: return family.previewSize
}
}
}
extension WidgetSnapshot {
/// What the widget gallery shows before the app ever wrote a snapshot, and
/// what the renderer draws: a trip to Kyoto with a full day, bookings and to-dos.
+134 -24
View File
@@ -85,6 +85,53 @@ struct TripContent: View {
}
.frame(width: 96, height: 96)
}
case .extraLarge:
// The iPad's wide widget: the trip and its countdown on the left, the
// bookings ahead in a column on the right.
HStack(alignment: .top, spacing: 20) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
Spacer(minLength: 0)
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true) }
}
Spacer(minLength: 0)
HStack(alignment: .bottom, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(trip.title)
.font(.system(size: 28, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.75)
if let place = trip.place {
Label(place, systemImage: "location.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
}
}
Spacer(minLength: 0)
countdown(days, snapshot.labels, size: 54)
}
RouteArc(progress: progress, color: .white)
.frame(height: 34)
.padding(.top, 10)
.padding(.bottom, 12)
TripFacts(snapshot: snapshot, onPhoto: true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
if let bookings = snapshot.bookings, !bookings.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text(snapshot.labels.bookings ?? "").trekEyebrowOnPhoto()
ForEach(Array(bookings.prefix(4).enumerated()), id: \.offset) { _, booking in
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
}
Spacer(minLength: 0)
}
.frame(width: 290)
.frame(maxHeight: .infinity, alignment: .top)
}
}
default:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
@@ -154,10 +201,17 @@ struct TripContent: View {
switch family {
case .small:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
Text(today.dayLabel).trekEyebrowOnPhoto()
Spacer(minLength: 0)
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
// An iPad mini's small widget has no room for both: the day stays, the weather gives way.
ViewThatFits(in: .horizontal) {
HStack(alignment: .top) {
Text(today.dayLabel).trekEyebrowOnPhoto()
Spacer(minLength: 0)
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
}
HStack(alignment: .top) {
Text(today.dayLabel).trekEyebrowOnPhoto()
Spacer(minLength: 0)
}
}
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 7)
Spacer(minLength: 0)
@@ -175,31 +229,87 @@ struct TripContent: View {
Text(trip.title).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)).lineLimit(1).padding(.top, 2)
}
case .medium:
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 0) {
Text(trip.title).trekEyebrowOnPhoto()
Text(today.dayLabel)
.font(.system(size: 21, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.8)
.padding(.top, 3)
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
Spacer(minLength: 0)
if let weather = snapshot.weather {
HStack(spacing: 6) {
WeatherChip(weather: weather, onPhoto: true)
if let high = weather.high, let low = weather.low {
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
// 176 pt for the stops on an iPhone and an 11" or 13" iPad; an iPad
// mini's medium widget is narrower, so the panel gives up some width
// and the weather keeps to its chip, and the day still reads whole.
// The line is drawn by the iPad mini alone: its medium widget is
// 305.5 pt wide, 273.5 inside the content margins, while the smallest
// iPhone's (a 4.7" SE) is 321 pt, 289 inside, and keeps the full layout.
GeometryReader { geo in
let narrow = geo.size.width < 281
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 0) {
Text(trip.title).trekEyebrowOnPhoto()
Text(today.dayLabel)
.font(.system(size: 21, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.8)
.padding(.top, 3)
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
Spacer(minLength: 0)
if let weather = snapshot.weather {
HStack(spacing: 6) {
WeatherChip(weather: weather, onPhoto: true, compact: narrow)
if !narrow, let high = weather.high, let low = weather.low {
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GlassPanel {
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
}
.frame(width: narrow ? 150 : 176)
}
.frame(maxWidth: .infinity, alignment: .leading)
GlassPanel {
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
.frame(width: geo.size.width, height: geo.size.height)
}
case .extraLarge:
// The iPad's wide widget: the day and the next stop on the left, the
// day's stops and the next booking on the right, each column
// anchored at its top and its foot.
HStack(alignment: .top, spacing: 20) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
Text(trip.title).trekEyebrowOnPhoto()
Text(today.dayLabel)
.font(.system(size: 26, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Spacer(minLength: 0)
if let weather = snapshot.weather { BigWeather(weather: weather) }
}
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 10)
Spacer(minLength: 8)
if let next {
Text(next.time ?? snapshot.labels.today)
.font(Trek.number(34, .heavy))
.foregroundStyle(.white)
Text(next.name)
.font(.system(size: 17, weight: .bold))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.85)
} else {
Text(snapshot.labels.nothingPlanned).font(.system(size: 17, weight: .bold)).foregroundStyle(.white).lineLimit(2)
}
}
.frame(width: 176)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 10) {
GlassPanel {
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 6, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
}
Spacer(minLength: 0)
if let booking = snapshot.bookings?.first {
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
}
}
.frame(width: 300)
.frame(maxHeight: .infinity, alignment: .top)
}
default:
VStack(alignment: .leading, spacing: 0) {
@@ -7,7 +7,11 @@ import WidgetKit
struct TripDayLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TripDayAttributes.self) { context in
TripDayBanner(attributes: context.attributes, state: context.state)
TripDayBanner(
tripTitle: context.attributes.tripTitle, dayLabel: context.state.dayLabel,
nextName: context.state.nextName, nextTime: context.state.nextTime,
done: context.state.done, total: context.state.total
)
.activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92))
.activitySystemActionForegroundColor(.white)
.widgetURL(WidgetSnapshot.link(context.attributes.path))
@@ -31,7 +35,7 @@ struct TripDayLiveActivity: Widget {
DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading, spacing: 8) {
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1)
StopProgress(done: state.done, total: state.total)
DayStopProgress(done: state.done, total: state.total)
}
.padding(.horizontal, 4)
}
@@ -55,58 +59,3 @@ struct TripDayLiveActivity: Widget {
}
}
}
/// The lock-screen banner: trip and day on top, the next stop large, the
/// day's stops as segments.
private struct TripDayBanner: View {
let attributes: TripDayAttributes
let state: TripDayAttributes.ContentState
var body: some View {
HStack(alignment: .center, spacing: 14) {
ZStack {
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
Circle()
.trim(from: 0, to: state.total > 0 ? max(0.02, Double(state.done) / Double(state.total)) : 0)
.stroke(
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
style: StrokeStyle(lineWidth: 5, lineCap: .round)
)
.rotationEffect(.degrees(-90))
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
}
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(attributes.tripTitle)
.font(.system(size: 11, weight: .bold))
.tracking(0.5)
.textCase(.uppercase)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
Spacer(minLength: 4)
Text(state.dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
}
HStack(alignment: .firstTextBaseline) {
Text(state.nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
Spacer(minLength: 4)
if let time = state.nextTime {
Text(time).font(Trek.number(18, .heavy)).foregroundStyle(.white)
}
}
StopProgress(done: state.done, total: state.total).padding(.top, 3)
}
}
.padding(16)
}
}
/// The day's timed stops as segments, those behind filled.
private struct StopProgress: View {
let done: Int
let total: Int
var body: some View {
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
}
}
@@ -51,7 +51,23 @@ struct Render {
}
let lock = LockScreen(snapshots: states.prefix(3).map(\.1), now: now).environment(\.colorScheme, scheme)
try write(lock, to: out.appendingPathComponent("lock-\(schemeName).png"))
// The iPad: every home-screen size of each widget as that iPad gives
// them, the extra large included, per kind in one strip.
for pad in TrekPad.allCases {
for (stateName, snapshot) in states where ["upcoming", "coverrunning", "empty"].contains(stateName) {
for kind in WidgetKind.allCases {
let strip = PadStrip(kind: kind, pad: pad, snapshot: snapshot, now: now)
.environment(\.colorScheme, scheme)
.environment(\.trekStaticPreview, true)
try write(strip, to: out.appendingPathComponent("pad-\(pad.rawValue)-\(kind.rawValue)-\(stateName)-\(schemeName).png"), scale: 2)
}
}
}
let padLock = PadLockScreen(snapshot: states[1].1, now: now).environment(\.colorScheme, scheme)
try write(padLock, to: out.appendingPathComponent("pad-lock-\(schemeName).png"), scale: 2)
}
try write(LiveActivityWidths(), to: out.appendingPathComponent("liveactivity-widths.png"))
let args = Array(CommandLine.arguments.dropFirst())
try Mockups.render(to: out, iconPath: args.count > 1 ? args[1] : nil, now: now)
print("wrote \(try FileManager.default.contentsOfDirectory(atPath: out.path).count) images to \(out.path)")
@@ -77,9 +93,9 @@ struct Render {
}
@MainActor
static func write<V: View>(_ view: V, to url: URL) throws {
static func write<V: View>(_ view: V, to url: URL, scale: CGFloat = 3) throws {
let renderer = ImageRenderer(content: view)
renderer.scale = 3
renderer.scale = scale
guard let image = renderer.nsImage,
let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
@@ -95,9 +111,9 @@ enum WidgetKind: String, CaseIterable {
var families: [TrekFamily] {
switch self {
case .trip: return [.small, .medium, .large, .circular, .rectangular, .inline]
case .today: return [.medium, .large]
case .bookings: return [.small, .medium, .large, .rectangular, .inline]
case .trip: return [.small, .medium, .large, .extraLarge, .circular, .rectangular, .inline]
case .today: return [.medium, .large, .extraLarge]
case .bookings: return [.small, .medium, .large, .extraLarge, .rectangular, .inline]
case .todos: return [.small, .medium, .large, .circular, .rectangular]
case .packing: return [.small, .medium, .large, .circular, .rectangular]
case .atlas: return [.small, .medium, .large, .circular, .rectangular, .inline]
@@ -113,9 +129,11 @@ struct WidgetFrame: View {
let family: TrekFamily
let snapshot: WidgetSnapshot
let now: Date
/// The device's size for the family, when it is not the 6.1" iPhone's.
var size: CGSize? = nil
var body: some View {
let size = family.previewSize
let size = self.size ?? family.previewSize
if family.isAccessory {
// As the lock screen draws them: white on the wallpaper, no box of their own
// (the circular one gets the system's soft disc).
@@ -270,3 +288,98 @@ struct NewWidgets: View {
)
}
}
/// One widget kind in every home-screen size an iPad gives it, side by side on
/// that iPad's wallpaper, the extra large last.
struct PadStrip: View {
let kind: WidgetKind
let pad: TrekPad
let snapshot: WidgetSnapshot
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
HStack(alignment: .top, spacing: 24) {
ForEach(kind.families.filter { !$0.isAccessory }, id: \.self) { family in
WidgetFrame(kind: kind, family: family, snapshot: snapshot, now: now, size: pad.size(family))
}
}
.padding(28)
.background(
LinearGradient(
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
startPoint: .topLeading, endPoint: .bottomTrailing
)
)
}
}
/// An 11" iPad's lock screen on its side: the clock with the accessories under
/// it on the left, as iPadOS places them, and the travel day's Live Activity.
struct PadLockScreen: View {
let snapshot: WidgetSnapshot
let now: Date
var body: some View {
HStack(alignment: .top, spacing: 0) {
VStack(alignment: .leading, spacing: 14) {
WidgetFrame(kind: .trip, family: .inline, snapshot: snapshot, now: now)
Text(now, format: .dateTime.hour().minute())
.font(.system(size: 96, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
HStack(spacing: 10) {
WidgetFrame(kind: .trip, family: .circular, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .circular, snapshot: snapshot, now: now)
WidgetFrame(kind: .packing, family: .circular, snapshot: snapshot, now: now)
}
WidgetFrame(kind: .trip, family: .rectangular, snapshot: snapshot, now: now)
WidgetFrame(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .rectangular, snapshot: snapshot, now: now)
}
.padding(.leading, 64)
.padding(.top, 70)
Spacer(minLength: 0)
}
.frame(width: 1194, height: 834, alignment: .topLeading)
.overlay(alignment: .bottom) {
LiveActivityCard(width: 500).padding(.bottom, 40)
}
.background(
LinearGradient(colors: [Color(hex: 0x2B3A67), Color(hex: 0x6B4A6E), Color(hex: 0x1C1726)], startPoint: .top, endPoint: .bottom)
)
}
}
/// The travel day's Live Activity as the lock screen tints it.
struct LiveActivityCard: View {
let width: CGFloat
var body: some View {
TripDayBanner(
tripTitle: "Autumn in Kyoto", dayLabel: "Day 3 of 9",
nextName: "Kiyomizu-dera", nextTime: "13:30", done: 2, total: 5
)
.frame(width: width)
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
}
}
/// The Live Activity at the widths an iPhone's and an iPad's lock screen give
/// it, and with a long German day and stop, to see it hold at each.
struct LiveActivityWidths: View {
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ForEach([364, 500, 640] as [CGFloat], id: \.self) { width in
LiveActivityCard(width: width)
}
TripDayBanner(
tripTitle: "Herbst in Kyoto und Nara", dayLabel: "Tag 3 von 9",
nextName: "Kiyomizu-dera und Sannenzaka-Spaziergang", nextTime: "13:30", done: 2, total: 5
)
.frame(width: 364)
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
}
.padding(28)
.background(Color(hex: 0x1E2A3A))
}
}
+7 -6
View File
@@ -66,12 +66,12 @@ wait_ready() {
TOKEN=0
# shot <udid> <file> <screen> <scenario> <time or -> <language>: one launch, retried
# after 2, 5 and 10 seconds when the simulator refuses it.
# after 2, 5, 10 and 20 seconds when the simulator refuses it, saying which step failed.
shot() {
local udid=$1 file=$2 screen=$3 scenario=$4 clock=$5 lang=$6
local locale=${lang//-/_} attempt
for attempt in 0 2 5 10; do
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s"; sleep "$attempt"; }
local locale=${lang//-/_} attempt failed=
for attempt in 0 2 5 10 20; do
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s ($failed)"; sleep "$attempt"; }
TOKEN=$((TOKEN + 1))
xcrun simctl terminate "$udid" "$BUNDLE" >/dev/null 2>&1 || true
sleep 0.5
@@ -79,11 +79,12 @@ shot() {
[ "$clock" = - ] || extra=(-TrekNow "$clock")
xcrun simctl launch "$udid" "$BUNDLE" \
-AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" \
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || continue
wait_ready "$udid" "$TOKEN" || continue
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || { failed=launch; continue; }
wait_ready "$udid" "$TOKEN" || { failed='not ready'; continue; }
# The map's tiles come over the network after the screen is up.
[ "$screen" = place ] && sleep 6
xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 && return 0
failed=screenshot
done
echo "gave up on $(basename "$file")"
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n'
import { SUPPORTED_LANGUAGE_CODES, isRtlLanguage } from '@trek/shared/i18n'
import type { TranslationStrings } from '@trek/shared/i18n'
// Only the native strings of every locale, not the whole UI: this page ships
@@ -32,3 +32,8 @@ export function translator(code: string) {
: key
}
}
/** Arabic and Hebrew run right to left. */
export function directionOf(code: string): 'rtl' | 'ltr' {
return isRtlLanguage(code) ? 'rtl' : 'ltr'
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#09090b" />
<title>TREK</title>
+459 -91
View File
@@ -1,3 +1,4 @@
import { App } from '@capacitor/app';
import { Capacitor, CapacitorHttp } from '@capacitor/core';
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
import { SplashScreen } from '@capacitor/splash-screen';
@@ -5,11 +6,28 @@ import { StatusBar, Style } from '@capacitor/status-bar';
import logoUrl from '../public/icons/icon-white.svg';
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
import { AUTH_GATE_NAMES, canSignInInApp, type AuthGate } from '../src/native/authGate';
import { opensSoon, refundedBeforeOpening } from '../src/native/cloudOrders';
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
import { CLOUD_TERMS_URL, CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
import { TrekShell } from '../src/native/trekShell';
import { pickLocale, translator } from './i18n';
import {
AUTH_PROXY_DOCS_URL,
CLIENT_CERT_DOCS_URL,
CLOUD_TERMS_URL,
CLOUD_URL,
PRIVACY_URL,
SERVER_DOCS_URL,
STORE_TERMS_URL,
} from '../src/native/links';
import {
probeDemo,
probeServer,
type ClientCertDemand,
type ProbeFailure,
type ProbeGet,
} from '../src/native/serverAddress';
import { passwordProblem } from '../../shared/src/auth/password-policy';
import { TrekShell, type ClientCertificateFailure } from '../src/native/trekShell';
import { directionOf, pickLocale, translator } from './i18n';
import './shell.css';
/**
@@ -24,7 +42,7 @@ const locale = pickLocale(navigator.languages?.length ? navigator.languages : [n
const onPlay = Capacitor.getPlatform() === 'android';
const t = translator(locale);
document.documentElement.lang = locale;
document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr';
document.documentElement.dir = directionOf(locale);
const root = document.getElementById('shell')!;
const PROBE_TIMEOUT_MS = 8000;
@@ -46,11 +64,34 @@ function cloudSuffix(): string {
}
}
/** JSON bodies arrive as text from the app's own request; everything else stays text. */
function parseBody(data: unknown): unknown {
if (typeof data !== 'string') return data;
try {
return JSON.parse(data);
} catch {
return data;
}
}
// Native HTTP rather than fetch: the probe goes to a server of the user's
// choosing, which has no reason to answer this page's origin with CORS.
// choosing, which has no reason to answer this page's origin with CORS. The
// app's own request comes first: it shows a stored client certificate and
// reports whether the server asked for one. A web preview has only Capacitor's.
const get: ProbeGet = async (url) => {
const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS });
return { status: response.status, data: response.data, url: response.url };
try {
const response = await TrekShell.requestServer({ url, timeoutMs: PROBE_TIMEOUT_MS });
return {
status: response.status,
data: parseBody(response.data),
url: response.url,
headers: response.headers,
clientCert: { requested: response.certRequested, sent: response.certSent },
};
} catch {
const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS });
return { status: response.status, data: response.data, url: response.url, headers: response.headers };
}
};
function failureMessage(reason: ProbeFailure): string {
@@ -61,6 +102,10 @@ function failureMessage(reason: ProbeFailure): string {
return t('native.connect.errorUnreachable');
case 'notTrek':
return t('native.connect.errorNotTrek');
case 'authGate':
return t('native.gate.titleUnknown');
case 'clientCert':
return t('native.cert.title');
case 'tooOld':
return t('native.connect.errorTooOld');
}
@@ -128,6 +173,11 @@ function show(screen: HTMLElement, opts: { back?: boolean } = {}): void {
if (opts.back) screen.classList.add('screen-back');
const previous = current;
if (previous) {
// The leaving screen is lifted out of the flow where it stands, so it fades
// from its own place rather than jumping to the top of a centred layout.
previous.style.top = `${previous.offsetTop}px`;
previous.style.left = `${previous.offsetLeft}px`;
previous.style.width = `${previous.offsetWidth}px`;
previous.classList.remove('screen-enter');
previous.classList.add('screen-leave');
if (opts.back) previous.classList.add('screen-back');
@@ -144,6 +194,15 @@ function externalLink(text: string, href: string): HTMLAnchorElement {
function footLinks(...extra: Node[]): HTMLElement {
return el('div', { className: 'foot-links' }, ...extra, externalLink(t('native.privacy'), PRIVACY_URL));
}
/**
* A screen's two halves: what it is (brand, title, explanation) and what to do
* on it (the choices, the form). Stacked in one column they read as before; on
* a phone or a foldable on its side the screen is too short for one column, so
* the halves stand side by side there (shell.css, `.col`).
*/
function columns(about: Node[], act: Node[]): HTMLElement[] {
return [el('div', { className: 'col col-about' }, ...about), el('div', { className: 'col col-act' }, ...act)];
}
function topbar(onBack: () => void): HTMLElement {
const back = el('button', { type: 'button', className: 'back' }, icon('M15 18l-6-6 6-6', 18), t('native.back'));
back.addEventListener('click', () => {
@@ -202,13 +261,14 @@ function showWelcome(opts: { back?: boolean } = {}): void {
el(
'div',
{},
hero,
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
el('p', { className: 'sub' }, t('native.welcome.sub')),
cloud,
connect,
demo,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
...columns(
[
hero,
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
el('p', { className: 'sub' }, t('native.welcome.sub')),
],
[cloud, connect, demo, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
)
),
opts
);
@@ -225,6 +285,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
});
input.setAttribute('inputmode', 'url');
input.setAttribute('autocomplete', 'url');
// An address reads left to right in every language, Arabic too.
input.dir = 'ltr';
// The on-screen keyboard's return key submits the address; it reads "Go", not a newline.
input.setAttribute('enterkeyhint', 'go');
input.setAttribute('aria-label', t('native.connect.title'));
@@ -233,24 +295,36 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
const label = el('span', {}, t('native.connect.submit'));
const submit = el('button', { type: 'submit', className: 'btn' }, label);
const error = el('p', { className: 'error', role: 'alert' });
const form = el('form', { className: 'form', noValidate: true }, field, submit, error);
const gatePanel = el('div', { className: 'gate', role: 'alert', hidden: true });
const form = el('form', { className: 'form', noValidate: true }, field, submit, error, gatePanel);
const fail = (reason: ProbeFailure): void => {
const fail = (reason: ProbeFailure, gate?: AuthGate, server?: string, cert?: ClientCertDemand): void => {
void notify(NotificationType.Error);
field.classList.add('bad');
setTimeout(() => field.classList.remove('bad'), 350);
error.textContent = failureMessage(reason);
if (reason === 'authGate' && gate && server) {
error.textContent = '';
fillGatePanel(gatePanel, gate, server);
} else if (reason === 'clientCert' && cert) {
error.textContent = '';
fillCertPanel(gatePanel, cert, () => form.requestSubmit());
} else {
error.textContent = failureMessage(reason);
}
input.disabled = false;
submit.disabled = false;
label.textContent = t('native.connect.submit');
submit.replaceChildren(label);
input.focus();
// A panel with its own next step stays in view; the keyboard would cover it.
if (gatePanel.hidden) input.focus();
};
form.addEventListener('submit', async (event) => {
event.preventDefault();
void tap(ImpactStyle.Medium);
error.textContent = '';
gatePanel.hidden = true;
gatePanel.replaceChildren();
input.disabled = true;
submit.disabled = true;
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'));
@@ -259,24 +333,145 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
await connected(result.url);
return;
}
fail(result.reason);
fail(result.reason, result.gate, result.url, result.cert);
});
show(
el(
'div',
{},
topbar(() => showWelcome({ back: true })),
el('h1', { className: 'title-sm' }, t('native.connect.title')),
el('p', { className: 'sub' }, t('native.connect.hint')),
form,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
...columns(
[
topbar(() => showWelcome({ back: true })),
el('h1', { className: 'title-sm' }, t('native.connect.title')),
el('p', { className: 'sub' }, t('native.connect.hint')),
],
[form, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
)
),
opts
);
requestAnimationFrame(() => input.focus());
}
/**
* A sign-in page of the user's own stands in front of the server (authGate.ts):
* say which one, offer to sign in to it inside the app, which keeps its session
* as a browser would, and how to let the app through instead.
*/
function fillGatePanel(panel: HTMLElement, gate: AuthGate, server: string): void {
const name = gate.kind === 'unknown' || gate.kind === 'basic' ? null : AUTH_GATE_NAMES[gate.kind];
const title =
gate.kind === 'basic'
? t('native.gate.titleBasic')
: name
? t('native.gate.title', { name })
: t('native.gate.titleUnknown');
const children: Node[] = [el('p', { className: 'gate-title' }, title)];
if (canSignInInApp(gate)) {
const signIn = el(
'button',
{ type: 'button', className: 'btn' },
name ? t('native.gate.signIn', { name }) : t('native.gate.signInGeneric')
);
signIn.addEventListener('click', () => {
void tap(ImpactStyle.Medium);
void connected(server, gate.loginHost ? [gate.loginHost] : []);
});
children.push(
el('p', { className: 'gate-text' }, t('native.gate.hint')),
signIn,
el('p', { className: 'gate-note' }, t('native.gate.googleNote'))
);
} else {
children.push(el('p', { className: 'gate-text' }, t('native.gate.basicHint')));
}
children.push(externalLink(t('native.gate.help'), AUTH_PROXY_DOCS_URL));
panel.replaceChildren(...children);
panel.hidden = false;
}
/**
* A proxy in front of the server lets only devices with a client certificate
* through (mutual TLS: Cloudflare, nginx, Caddy, Traefik). The panel offers to
* add the certificate file for it, or another one when the proxy refused the
* one shown; `retry` checks the server again once it is added.
*/
function fillCertPanel(panel: HTMLElement, cert: ClientCertDemand, retry: () => void): void {
const status = el('p', { className: 'gate-note', role: 'status' });
const choose = el(
'button',
{ type: 'button', className: 'btn' },
cert.sent ? t('native.cert.chooseOther') : t('native.cert.choose')
);
choose.addEventListener('click', async () => {
void tap(ImpactStyle.Medium);
choose.disabled = true;
status.textContent = '';
const outcome = await importCertificate(cert.host, cert.port);
choose.disabled = false;
if (outcome === 'imported') {
void notify(NotificationType.Success);
retry();
} else if (outcome !== 'cancelled') {
void notify(NotificationType.Error);
status.textContent = certFailureMessage(outcome);
}
});
panel.replaceChildren(
el('p', { className: 'gate-title' }, cert.sent ? t('native.cert.titleRefused') : t('native.cert.title')),
el(
'p',
{ className: 'gate-text' },
cert.sent ? t('native.cert.hintRefused') : t('native.cert.hint', { host: cert.host })
),
choose,
status,
externalLink(t('native.cert.help'), CLIENT_CERT_DOCS_URL)
);
panel.hidden = false;
}
/** Imports a certificate file for host and port: 'imported', 'cancelled', or why it failed. */
async function importCertificate(
host: string,
port: number
): Promise<'imported' | 'cancelled' | ClientCertificateFailure> {
try {
const result = await TrekShell.importClientCertificate({
host,
port,
labels: {
title: t('native.cert.passwordTitle'),
message: t('native.cert.passwordMessage'),
placeholder: t('native.cert.passwordPlaceholder'),
ok: t('native.cert.passwordOk'),
cancel: t('native.cert.passwordCancel'),
wrongPassword: t('native.cert.passwordWrong'),
},
});
if (result.result === 'failed') return result.failure ?? 'format';
return result.result;
} catch {
return 'store';
}
}
function certFailureMessage(failure: ClientCertificateFailure): string {
switch (failure) {
case 'no_key':
return t('native.cert.errorNoKey');
case 'expired':
return t('native.cert.errorExpired');
case 'not_yet_valid':
return t('native.cert.errorNotYetValid');
case 'store':
return t('native.cert.errorStore');
case 'format':
return t('native.cert.errorFormat');
}
}
// ── Get TREK Cloud ──────────────────────────────────────────────────────────
function feature(path: string, text: string): HTMLElement {
return el(
@@ -288,7 +483,13 @@ function feature(path: string, text: string): HTMLElement {
}
function showCloud(opts: { back?: boolean } = {}): void {
const cta = el('button', { type: 'button', className: 'btn' }, t('native.cloud.cta'), icon(I.chevron, 20, 'chev'));
// The label centred, the chevron at the far edge: it points on, not at the word.
const cta = el(
'button',
{ type: 'button', className: 'btn btn-go' },
el('span', { className: 'btn-label' }, t('native.cloud.cta')),
icon(I.chevron, 20, 'chev')
);
cta.addEventListener('click', () => {
void tap();
showPlans();
@@ -304,25 +505,32 @@ function showCloud(opts: { back?: boolean } = {}): void {
el(
'div',
{},
topbar(() => showWelcome({ back: true })),
el(
'div',
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title') },
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
el('span', { className: 'cloud-tag' }, 'cloud')
),
el('p', { className: 'sub' }, t('native.cloud.sub')),
el(
'div',
{ className: 'features' },
feature(I.globe, t('native.cloud.f1')),
feature(I.bolt, t('native.cloud.f2')),
feature(I.shield, t('native.cloud.f3'))
),
cta,
have,
footLinks()
...columns(
[
topbar(() => showWelcome({ back: true })),
// The lockup is a logo: "trek" then "cloud" in every language, never mirrored.
el(
'div',
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title'), dir: 'ltr' },
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
el('span', { className: 'cloud-tag' }, 'cloud')
),
el('p', { className: 'sub' }, t('native.cloud.sub')),
],
[
el(
'div',
{ className: 'features' },
feature(I.globe, t('native.cloud.f1')),
feature(I.bolt, t('native.cloud.f2')),
feature(I.shield, t('native.cloud.f3'))
),
cta,
have,
footLinks(),
]
)
),
opts
);
@@ -396,12 +604,14 @@ function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): {
async function loadOffer(list: CloudPlans): Promise<Offer> {
const available = await iapReady;
// Closed: a store purchase would be refused at the cloud, so only a test build may buy.
const opensSoon = available && list.closed && !(await iap.isSandbox().catch(() => false));
const canBuy = available && !opensSoon;
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon };
// Closed: a real purchase is refused at the cloud, so only a test build may buy on iOS;
// Android keeps the button for the licence testers (see cloudOrders.ts).
const sandbox = list.closed && !onPlay ? await iap.isSandbox().catch(() => false) : false;
const soon = opensSoon({ available, closed: list.closed, sandbox, onPlay });
const canBuy = available && !soon;
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon: soon };
const ids = list.plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
return { canBuy, storePrices: await iap.prices(ids), opensSoon };
return { canBuy, storePrices: await iap.prices(ids), opensSoon: soon };
}
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
@@ -427,7 +637,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HT
{ className: 'plan-top' },
el('span', { className: 'plan-name' }, plan.name),
// No price yet (orders not open): the plan shows without one rather than with an empty one.
...(label ? [el('span', { className: 'plan-price' }, label, el('small', {}, per))] : [])
// The price is figures and a currency sign: one left-to-right run, so Arabic
// keeps "5,99 €" whole, with the period standing apart beside it.
...(label ? [el('span', { className: 'plan-price' }, el('bdi', { dir: 'ltr' }, label), el('small', {}, per))] : [])
),
el(
'div',
@@ -491,7 +703,10 @@ function planFooter(offer: Offer, plans: CloudPlan[]): Node[] {
void notify(NotificationType.Error);
restore.disabled = false;
status.className = 'hint bad';
status.textContent = t('native.plans.restoreFailed');
// Bought before the opening: refused, unacknowledged, and refunded by Google Play.
status.textContent = refundedBeforeOpening(redeemed.code, onPlay)
? t('native.claim.closedRefund')
: t('native.plans.restoreFailed');
});
// A payment that was pending (cash, some carrier billing) and has cleared since:
@@ -578,12 +793,15 @@ function showPlans(opts: { back?: boolean } = {}): void {
el(
'div',
{},
topbar(() => showCloud({ back: true })),
el('h1', { className: 'title-sm' }, t('native.plans.title')),
el('p', { className: 'sub' }, t('native.plans.sub')),
seg,
content,
footer
...columns(
[
topbar(() => showCloud({ back: true })),
el('h1', { className: 'title-sm' }, t('native.plans.title')),
el('p', { className: 'sub' }, t('native.plans.sub')),
seg,
],
[content, footer]
)
),
opts
);
@@ -609,10 +827,12 @@ interface RedeemResult {
ok: boolean;
/** Set when ok: the address the app loads. */
instanceUrl?: string;
/** Set when ok and a password was chosen: the one-time code that signs the buyer in once the instance answers. */
welcome?: string;
/** Set when not ok: a stable refusal code. */
code?: string;
}
async function redeemPurchase(slug: string, email: string, purchase: Purchase): Promise<RedeemResult> {
async function redeemPurchase(slug: string, email: string, purchase: Purchase, password = ''): Promise<RedeemResult> {
try {
const res = await CapacitorHttp.post({
url: `${CLOUD_URL}/api/iap/redeem`,
@@ -626,13 +846,16 @@ async function redeemPurchase(slug: string, email: string, purchase: Purchase):
receipt: purchase.receipt,
slug,
email,
// Hashed by the control plane on arrival and set on the new instance; empty for a restore.
...(password ? { password } : {}),
locale,
},
});
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
const body = (data ?? {}) as { instanceUrl?: string; code?: string };
const body = (data ?? {}) as { instanceUrl?: string; code?: string; welcome?: string };
if ((res.status === 200 || res.status === 201) && typeof body.instanceUrl === 'string') {
return { ok: true, instanceUrl: body.instanceUrl };
const welcome = typeof body.welcome === 'string' && /^[A-Za-z0-9_-]{43}$/.test(body.welcome) ? body.welcome : undefined;
return { ok: true, instanceUrl: body.instanceUrl, ...(welcome ? { welcome } : {}) };
}
return { ok: false, code: body.code ?? 'redeem-failed' };
} catch {
@@ -661,9 +884,11 @@ function showClaim(
slugInput.setAttribute('autocomplete', 'off');
slugInput.setAttribute('maxlength', '40');
slugInput.setAttribute('aria-label', t('native.claim.addressLabel'));
slugInput.setAttribute('enterkeyhint', 'next');
// "name.liketrek.com" is one address, left to right in every language.
const slugField = el(
'div',
{ className: 'field slug-field' },
{ className: 'field slug-field', dir: 'ltr' },
slugInput,
el('span', { className: 'slug-suffix' }, cloudSuffix())
);
@@ -678,10 +903,29 @@ function showClaim(
});
emailInput.setAttribute('inputmode', 'email');
emailInput.setAttribute('autocomplete', 'email');
emailInput.setAttribute('enterkeyhint', 'go');
emailInput.setAttribute('enterkeyhint', 'next');
emailInput.setAttribute('aria-label', t('native.claim.emailLabel'));
emailInput.dir = 'ltr';
emailInput.classList.add('ltr-value');
const emailField = el('div', { className: 'field' }, emailInput);
// The password for the new TREK, chosen here so the buyer lands signed in (no password by mail).
const passwordInput = el('input', {
type: 'password',
value: '',
autocapitalize: 'off',
spellcheck: false,
placeholder: t('native.claim.passwordLabel'),
});
passwordInput.setAttribute('autocomplete', 'new-password');
passwordInput.setAttribute('enterkeyhint', 'go');
passwordInput.setAttribute('maxlength', '128');
passwordInput.setAttribute('aria-label', t('native.claim.passwordLabel'));
passwordInput.dir = 'ltr';
passwordInput.classList.add('ltr-value');
const passwordField = el('div', { className: 'field' }, passwordInput);
const passwordHint = el('p', { className: 'hint' }, t('native.claim.passwordHint'));
const submit = el('button', { type: 'submit', className: 'btn' }, t('native.claim.subscribe', { price }));
const error = el('p', { className: 'error', role: 'alert' });
const form = el(
@@ -691,6 +935,8 @@ function showClaim(
slugNote,
emailField,
el('p', { className: 'hint' }, t('native.claim.emailHint')),
passwordField,
passwordHint,
// Our terms govern the instance (terms § 5a): named before the purchase, not only in the confirmation mail.
el(
'p',
@@ -703,6 +949,17 @@ function showClaim(
error
);
// "Next" moves on to the next field instead of sending the form half filled;
// "Go" on the password sends it.
const nextOnEnter = (from: HTMLInputElement, to: HTMLInputElement) =>
from.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || e.isComposing) return;
e.preventDefault();
to.focus();
});
nextOnEnter(slugInput, emailInput);
nextOnEnter(emailInput, passwordInput);
let status: SlugStatus | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
const noteFor: Partial<Record<SlugStatus, string>> = {
@@ -749,11 +1006,23 @@ function showClaim(
emailInput.focus();
return;
}
// Before anything is bought: a password TREK would refuse must not cost a purchase first.
const password = passwordInput.value;
if (passwordProblem(password) || password.length > 128) {
shake(passwordField);
passwordHint.className = 'hint bad';
passwordInput.focus();
return;
}
passwordHint.className = 'hint';
void tap(ImpactStyle.Medium);
// The purchase sheet and the progress want the whole screen, not the keyboard.
passwordInput.blur();
const lock = (on: boolean) => {
slugInput.disabled = on;
emailInput.disabled = on;
passwordInput.disabled = on;
submit.disabled = on;
};
lock(true);
@@ -784,10 +1053,10 @@ function showClaim(
bought = outcome.purchase;
}
const redeemed = await redeemPurchase(slug, email, bought);
const redeemed = await redeemPurchase(slug, email, bought, password);
if (redeemed.ok && redeemed.instanceUrl) {
await iap.finish(bought);
showProvisioning(redeemed.instanceUrl);
showProvisioning(redeemed.instanceUrl, redeemed.welcome);
return;
}
void notify(NotificationType.Error);
@@ -796,8 +1065,14 @@ function showClaim(
shake(slugField);
slugNote.textContent = t('native.claim.taken');
slugNote.className = 'hint bad';
} else if (redeemed.code === 'password-weak') {
// A common password the list on the server knows: the purchase waits, unredeemed, for a better one.
shake(passwordField);
passwordHint.className = 'hint bad';
} else {
error.textContent = t('native.claim.redeemFailed');
error.textContent = refundedBeforeOpening(redeemed.code, onPlay)
? t('native.claim.closedRefund')
: t('native.claim.redeemFailed');
}
});
@@ -805,10 +1080,14 @@ function showClaim(
el(
'div',
{},
topbar(() => showPlans({ back: true })),
el('h1', { className: 'title-sm' }, t('native.claim.title')),
el('p', { className: 'sub' }, t('native.claim.sub')),
form
...columns(
[
topbar(() => showPlans({ back: true })),
el('h1', { className: 'title-sm' }, t('native.claim.title')),
el('p', { className: 'sub' }, t('native.claim.sub')),
],
[form]
)
),
opts
);
@@ -816,7 +1095,7 @@ function showClaim(
}
/** The build runs on the server; the app waits on the new address and loads it the moment it answers. */
function showProvisioning(instanceUrl: string): void {
function showProvisioning(instanceUrl: string, welcome?: string): void {
const host = (() => {
try {
return new URL(instanceUrl).host;
@@ -840,11 +1119,11 @@ function showProvisioning(instanceUrl: string): void {
const poll = async (): Promise<void> => {
const result = await probeServer(instanceUrl, get);
if (result.ok) {
await connected(result.url);
await connected(result.url, [], welcome);
return;
}
if (Date.now() > deadline) {
showProvisioningSlow(instanceUrl);
showProvisioningSlow(instanceUrl, welcome);
return;
}
setTimeout(() => void poll(), 4000);
@@ -852,12 +1131,12 @@ function showProvisioning(instanceUrl: string): void {
setTimeout(() => void poll(), 3000);
}
function showProvisioningSlow(instanceUrl: string): void {
function showProvisioningSlow(instanceUrl: string, welcome?: string): void {
void notify(NotificationType.Warning);
const retry = el('button', { type: 'button', className: 'btn' }, t('native.provisioning.retry'));
retry.addEventListener('click', () => {
void tap();
showProvisioning(instanceUrl);
showProvisioning(instanceUrl, welcome);
});
show(
el(
@@ -888,8 +1167,13 @@ async function runDemo(button: HTMLButtonElement, error?: HTMLElement): Promise<
}
}
/** The tick, then the server takes over. A beat so the success registers. */
async function connected(url: string): Promise<void> {
/**
* The tick, then the server takes over. A beat so the success registers. With a
* welcome code (a purchase with a chosen password) the app opens the instance's
* welcome page once, which signs the buyer in; an app build without that option
* opens the sign-in page instead, where the chosen password works.
*/
async function connected(url: string, signInHosts: string[] = [], welcome?: string): Promise<void> {
void notify(NotificationType.Success);
const host = (() => {
try {
@@ -914,11 +1198,17 @@ async function connected(url: string): Promise<void> {
el('div', { className: 'success' }, tick, el('h1', { className: 'title-sm' }, t('native.connecting', { host })))
);
await new Promise((r) => setTimeout(r, 700));
await TrekShell.setServer({ url });
await TrekShell.setServer({
url,
...(signInHosts.length ? { signInHosts } : {}),
...(welcome ? { welcome } : {}),
});
}
function showUnreachable(server: string): void {
const retry = el('button', { type: 'button', className: 'btn' }, t('native.offline.retry'));
function showUnreachable(server: string, cert?: ClientCertDemand): void {
const certPanel = el('div', { className: 'gate', role: 'alert', hidden: true });
if (cert) fillCertPanel(certPanel, cert, () => void TrekShell.reload());
const retry = el('button', { type: 'button', className: cert ? 'linkbtn' : 'btn' }, t('native.offline.retry'));
retry.addEventListener('click', () => {
void tap();
retry.disabled = true;
@@ -933,12 +1223,20 @@ function showUnreachable(server: string): void {
el(
'div',
{},
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
el('h1', { className: 'title-sm' }, t('native.offline.title')),
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
retry,
change,
footLinks()
...columns(
[
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
el('h1', { className: 'title-sm' }, cert ? t('native.cert.unreachableTitle') : t('native.offline.title')),
el(
'p',
{ className: 'sub' },
cert
? t('native.cert.unreachableHint', { server: new URL(server).host })
: t('native.offline.hint', { server: new URL(server).host })
),
],
[certPanel, retry, change, footLinks()]
)
)
);
}
@@ -958,8 +1256,71 @@ async function paintStatusBar(): Promise<void> {
}
}
/**
* Android's back gesture steps back through the first screens as their own
* back button does, and only leaves the app from a screen that has none.
*/
function followBackGesture(): void {
try {
void App.addListener('backButton', () => {
const back = current?.querySelector<HTMLButtonElement>('.topbar .back');
if (back) back.click();
else void App.minimizeApp();
});
} catch {
/* web / unavailable: no system back gesture */
}
}
/**
* The keyboard closes the ways it does in an iOS app: a tap beside the fields,
* or a drag of the screen. A web view keeps it open for both, and on the claim
* screen the keyboard covered the hints and the terms with no way to put it away.
*/
function letKeyboardClose(): void {
const isField = (target: EventTarget | null) =>
target instanceof Element && !!target.closest('input, textarea, select, [contenteditable="true"], .field');
const close = () => {
const active = document.activeElement;
if (active instanceof HTMLElement && isField(active)) active.blur();
};
document.addEventListener('pointerdown', (e) => {
if (!isField(e.target)) close();
}, true);
let startY: number | null = null;
document.addEventListener('touchstart', (e) => {
startY = isField(e.target) ? null : (e.touches[0]?.clientY ?? null);
}, { passive: true });
document.addEventListener('touchmove', (e) => {
const y = e.touches[0]?.clientY;
if (startY !== null && y !== undefined && Math.abs(y - startY) > 10) {
startY = null;
close();
}
}, { passive: true });
}
/**
* Whether the remembered server failed for want of a client certificate. Asked
* only when the app saw that server ask for one in this run, and then checked
* once more, so a server that is merely offline is not taken for one that
* refused the certificate.
*/
async function certificateDemand(server: string): Promise<ClientCertDemand | null> {
try {
const { request } = await TrekShell.clientCertificate();
if (!request || request.host !== new URL(server).hostname.toLowerCase()) return null;
const result = await probeServer(server, get);
return result.ok ? null : (result.cert ?? null);
} catch {
return null;
}
}
async function start(): Promise<void> {
void paintStatusBar();
followBackGesture();
letKeyboardClose();
let url: string | null;
try {
({ url } = await TrekShell.getServer());
@@ -974,11 +1335,18 @@ async function start(): Promise<void> {
if (!url) {
showWelcome();
} else {
// Only reached when loading the server failed. On iOS the stored copy of
// its start page brings the offline-capable UI up anyway.
const { opened } = await TrekShell.openOfflineCopy();
if (opened) return;
showUnreachable(url);
// Only reached when loading the server failed. A proxy that wanted a client
// certificate says so here, before the offline copy would start a UI that
// cannot reach its server.
const cert = await certificateDemand(url);
if (cert) {
showUnreachable(url, cert);
} else {
// On iOS the stored copy of its start page brings the offline-capable UI up anyway.
const { opened } = await TrekShell.openOfflineCopy();
if (opened) return;
showUnreachable(url);
}
}
await SplashScreen.hide();
}
+368 -42
View File
@@ -38,6 +38,45 @@
font-display: swap;
src: url('./fonts/museomoderno-latin-600.woff2') format('woff2');
}
/*
* Poppins and MuseoModerno have no Cyrillic, so Russian and Ukrainian fell back
* to the system's plain sans. Each family gets a Cyrillic-only stand-in under its
* own name, picked for the same build: Montserrat for the geometric text face,
* Comfortaa for the rounded headline. The unicode-range keeps Latin on TREK's
* own faces and loads these only when a Cyrillic letter is on screen.
*/
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./fonts/montserrat-cyrillic-500.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('./fonts/montserrat-cyrillic-600.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/montserrat-cyrillic-700.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'MuseoModerno';
font-style: normal;
font-weight: 600 700;
font-display: swap;
src: url('./fonts/comfortaa-cyrillic-700.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */
@font-face {
font-family: 'MuseoModerno';
@@ -64,6 +103,19 @@
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
--grid: rgba(255, 255, 255, 0.055);
--wash-1: rgba(129, 140, 248, 0.12);
/* Mixes with transparent, spelled out per scheme: older Android web views (Chrome 113)
resolved color-mix() towards transparent as opaque and drew solid halos and rings. */
--accent-ink-a16: rgba(9, 9, 11, 0.16);
--accent-ink-a35: rgba(9, 9, 11, 0.35);
--danger-a16: rgba(248, 113, 113, 0.16);
--ink-a22: rgba(250, 250, 250, 0.22);
--ink-a3: rgba(250, 250, 250, 0.03);
--ink-a4: rgba(250, 250, 250, 0.04);
--ink-a9: rgba(250, 250, 250, 0.09);
--ok-a16: rgba(74, 222, 128, 0.16);
--ok-a22: rgba(74, 222, 128, 0.22);
--wash-1-a85: rgba(129, 140, 248, 0.102);
--wash-1-a90: rgba(129, 140, 248, 0.108);
--wash-2: rgba(244, 114, 182, 0.08);
--veil-top: rgba(9, 9, 11, 0.4);
--veil-mid: rgba(9, 9, 11, 0.72);
@@ -71,8 +123,13 @@
--radius: 16px;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
/* 1 left to right, -1 in Arabic: a screen that comes next slides in from the trailing side. */
--flow: 1;
color-scheme: dark;
}
:root[dir='rtl'] {
--flow: -1;
}
@media (prefers-color-scheme: light) {
:root {
@@ -91,15 +148,29 @@
--ok: #16a34a;
--grid: rgba(17, 24, 39, 0.075);
--wash-1: rgba(79, 70, 229, 0.1);
--accent-ink-a16: rgba(255, 255, 255, 0.16);
--accent-ink-a35: rgba(255, 255, 255, 0.35);
--danger-a16: rgba(220, 38, 38, 0.16);
--ink-a22: rgba(17, 24, 39, 0.22);
--ink-a3: rgba(17, 24, 39, 0.03);
--ink-a4: rgba(17, 24, 39, 0.04);
--ink-a9: rgba(17, 24, 39, 0.09);
--ok-a16: rgba(22, 163, 74, 0.16);
--ok-a22: rgba(22, 163, 74, 0.22);
--wash-1-a85: rgba(79, 70, 229, 0.085);
--wash-1-a90: rgba(79, 70, 229, 0.09);
--wash-2: rgba(219, 39, 119, 0.07);
--veil-top: rgba(255, 255, 255, 0.12);
--veil-mid: rgba(255, 255, 255, 0.74);
color-scheme: light;
}
.shell-logo,
.mark {
filter: none;
}
/* The app icon is drawn white; on the light surface it turns black. */
.shell-logo {
filter: brightness(0);
}
}
* {
@@ -121,6 +192,9 @@ body {
text-rendering: optimizeLegibility;
user-select: none;
-webkit-user-select: none;
/* A double tap on a button zoomed the screen on iOS. An app does not zoom:
taps stay taps, panning still scrolls. */
touch-action: manipulation;
}
/*
@@ -167,6 +241,18 @@ body::after {
pointer-events: none;
z-index: 0;
}
/* In Arabic the photo holds the leading (right) side and the grid the other. */
:root[dir='rtl'] body::after {
background-position:
0 0,
right 28%;
-webkit-mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
}
:root[dir='rtl'] body::before {
-webkit-mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
}
.shell {
position: relative;
@@ -175,11 +261,12 @@ body::after {
min-height: 100dvh;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 440px;
margin: 0 auto;
padding: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
overflow: hidden;
/* Clip only the sideways slide: a screen taller than the window scrolls the
page instead (and a sticky column needs an ancestor that does not scroll). */
overflow-x: clip;
}
/* ── Screen transitions ──────────────────────────────────────────────────── */
@@ -187,14 +274,22 @@ body::after {
display: flex;
flex-direction: column;
gap: 14px;
/* Centred while it fits; taller than the window it starts at the top and the
page scrolls, rather than overflowing both edges as justify-content would. */
margin-block: auto;
}
/* A screen's two halves (main.ts, columns()): one column unless the layout asks. */
.col {
display: contents;
}
.screen-enter {
animation: screenIn 0.5s var(--ease) both;
}
/* Lifted out where it stood: main.ts sets its top, left and width. */
.screen-leave {
animation: screenOut 0.28s var(--ease-in) both;
position: absolute;
inset: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
margin: 0;
}
.screen-back.screen-enter {
animation-name: screenInBack;
@@ -205,7 +300,7 @@ body::after {
@keyframes screenIn {
from {
opacity: 0;
transform: translateX(26px);
transform: translateX(calc(26px * var(--flow)));
}
to {
opacity: 1;
@@ -215,13 +310,13 @@ body::after {
@keyframes screenOut {
to {
opacity: 0;
transform: translateX(-22px);
transform: translateX(calc(-22px * var(--flow)));
}
}
@keyframes screenInBack {
from {
opacity: 0;
transform: translateX(-26px);
transform: translateX(calc(-26px * var(--flow)));
}
to {
opacity: 1;
@@ -231,14 +326,40 @@ body::after {
@keyframes screenOutBack {
to {
opacity: 0;
transform: translateX(22px);
transform: translateX(calc(22px * var(--flow)));
}
}
/* Children stagger in, so a screen assembles rather than snaps. */
.screen-enter > * {
/* Children stagger in, so a screen assembles rather than snaps. Inside the two
halves the explanation comes first and the choices follow. */
.screen-enter > :not(.col),
.screen-enter > .col > * {
animation: riseIn 0.55s var(--ease) both;
}
.screen-enter > .col > :nth-child(1) {
animation-delay: 0.02s;
}
.screen-enter > .col > :nth-child(2) {
animation-delay: 0.07s;
}
.screen-enter > .col > :nth-child(3) {
animation-delay: 0.12s;
}
.screen-enter > .col > :nth-child(4) {
animation-delay: 0.17s;
}
.screen-enter > .col-act > :nth-child(1) {
animation-delay: 0.15s;
}
.screen-enter > .col-act > :nth-child(2) {
animation-delay: 0.2s;
}
.screen-enter > .col-act > :nth-child(3) {
animation-delay: 0.25s;
}
.screen-enter > .col-act > :nth-child(4) {
animation-delay: 0.3s;
}
.screen-enter > *:nth-child(1) {
animation-delay: 0.02s;
}
@@ -307,16 +428,16 @@ body::after {
height: 80px;
margin-bottom: 20px;
animation: floaty 6s ease-in-out infinite;
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
drop-shadow(0 6px 16px var(--ink-a22));
}
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
.hero-logo {
display: block;
margin-bottom: 22px;
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
drop-shadow(0 6px 16px var(--ink-a22));
animation: logoRise 0.7s var(--ease) both;
}
.hero-logo img {
@@ -364,6 +485,7 @@ body::after {
margin: 0;
/* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */
white-space: pre-line;
text-wrap: balance;
}
/* The welcome headline as the brand says it: MuseoModerno, lower case. */
.brand-title {
@@ -380,6 +502,8 @@ body::after {
display: inline-flex;
align-items: center;
gap: 9px;
/* Left to right inside (dir="ltr"), yet on the screen's start edge in Arabic. */
align-self: flex-start;
}
.cloud-wordmark img {
display: block;
@@ -414,6 +538,7 @@ body::after {
font-weight: 700;
letter-spacing: -0.025em;
margin: 0;
text-wrap: balance;
}
.sub {
color: var(--muted);
@@ -421,6 +546,8 @@ body::after {
line-height: 1.55;
font-size: 0.9rem;
overflow-wrap: anywhere;
/* No lone last word ("AI.") on a line of its own. */
text-wrap: balance;
}
/* ── Buttons ─────────────────────────────────────────────────────────────── */
@@ -428,9 +555,10 @@ body::after {
font: inherit;
font-weight: 600;
font-size: 1.02rem;
padding: 16px 18px;
padding: 16px 22px;
border: 1px solid transparent;
border-radius: var(--radius);
/* A pill, as the app's buttons are. */
border-radius: 999px;
background: var(--accent);
color: var(--accent-ink);
cursor: pointer;
@@ -455,13 +583,18 @@ body::after {
.btn:disabled {
opacity: 0.55;
}
/* A two-line choice (welcome) is a card rather than a button: the app's card radius. */
.btn:has(.btn-row) {
border-radius: 22px;
padding-inline: 18px;
}
.btn-ghost {
background: var(--surface);
color: var(--ink);
border-color: var(--edge);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.22),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent);
inset 0 0 0 1px var(--ink-a4);
}
.btn-ghost:active {
background: var(--surface-2);
@@ -471,6 +604,8 @@ body::after {
flex-direction: column;
align-items: flex-start;
gap: 2px;
/* A subtitle that wraps keeps to the start edge under its title. */
text-align: start;
}
.btn-row b {
font-weight: 650;
@@ -481,9 +616,20 @@ body::after {
opacity: 0.7;
}
.btn .chev {
margin-left: auto;
flex: none;
margin-inline-start: auto;
opacity: 0.5;
}
/* A centred label with the chevron at the far edge, the label optically centred. */
.btn-go .btn-label {
flex: 1;
padding-inline-start: 20px;
}
/* Chevrons point along the reading direction. */
:root[dir='rtl'] .chev,
:root[dir='rtl'] .back svg {
transform: scaleX(-1);
}
.linkbtn {
font: inherit;
@@ -507,7 +653,7 @@ body::after {
margin-top: 2px;
padding: 9px 18px;
min-height: 40px;
border-radius: var(--radius);
border-radius: 999px;
border: 1px solid var(--edge);
background: transparent;
color: var(--muted);
@@ -565,15 +711,15 @@ a.linkbtn {
width: 100%;
font: inherit;
font-size: 1rem;
padding: 13px 16px;
border-radius: var(--radius);
padding: 13px 18px;
border-radius: 999px;
border: 1px solid var(--edge);
background: var(--surface);
color: var(--ink);
outline: none;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
inset 0 1px 0 var(--ink-a4);
transition:
border-color 0.2s,
box-shadow 0.25s;
@@ -585,11 +731,11 @@ a.linkbtn {
}
.field input:focus {
border-color: var(--edge-strong);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
box-shadow: 0 0 0 4px var(--ink-a9);
}
.field.bad input {
border-color: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
box-shadow: 0 0 0 4px var(--danger-a16);
animation: shake 0.3s;
}
@keyframes shake {
@@ -613,11 +759,48 @@ a.linkbtn {
transition: opacity 0.2s;
}
/* A sign-in proxy in front of the server: which one, sign in to it here, or let the app through. */
.gate {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 18px;
border-radius: 22px;
background: var(--surface);
border: 1px solid var(--edge);
text-align: start;
}
.gate[hidden] {
display: none;
}
.gate-title {
margin: 0;
font-weight: 600;
color: var(--ink);
line-height: 1.4;
}
.gate-text {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.5;
}
.gate-note {
margin: 0;
color: var(--faint);
font-size: 0.82rem;
line-height: 1.45;
}
.gate a {
color: var(--ink);
font-size: 0.9rem;
}
/* Spinner inside the connect button while probing. */
.spinner {
width: 18px;
height: 18px;
border: 2px solid color-mix(in srgb, var(--accent-ink) 35%, transparent);
border: 2px solid var(--accent-ink-a35);
border-top-color: var(--accent-ink);
border-radius: 50%;
animation: spin 0.7s linear infinite;
@@ -672,7 +855,7 @@ a.linkbtn {
width: 72px;
height: 72px;
border-radius: 50%;
background: color-mix(in srgb, var(--ok) 16%, transparent);
background: var(--ok-a16);
display: grid;
place-items: center;
margin-bottom: 8px;
@@ -707,7 +890,8 @@ a.linkbtn {
.topbar {
display: flex;
align-items: center;
margin: 0 0 2px -8px;
margin-block: 0 2px;
margin-inline-start: -8px;
}
.back {
font: inherit;
@@ -741,7 +925,7 @@ a.linkbtn {
padding: 4px;
background: var(--surface);
border: 1px solid var(--edge);
border-radius: 13px;
border-radius: 999px;
align-self: stretch;
}
.segmented button {
@@ -751,7 +935,7 @@ a.linkbtn {
font-size: 0.95rem;
padding: 9px;
border: 0;
border-radius: 9px;
border-radius: 999px;
background: none;
color: var(--muted);
cursor: pointer;
@@ -772,11 +956,11 @@ a.linkbtn {
font-weight: 600;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--ok) 22%, transparent);
background: var(--ok-a22);
color: var(--ok);
}
.segmented button[aria-pressed='true'] .save {
background: color-mix(in srgb, var(--accent-ink) 16%, transparent);
background: var(--accent-ink-a16);
color: var(--accent-ink);
}
@@ -792,7 +976,7 @@ a.linkbtn {
background: var(--surface);
box-shadow:
0 2px 10px -4px rgba(0, 0, 0, 0.3),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 3%, transparent);
inset 0 0 0 1px var(--ink-a3);
transition:
border-color 0.2s,
box-shadow 0.2s,
@@ -800,7 +984,7 @@ a.linkbtn {
}
.plan.featured {
box-shadow:
0 10px 30px -10px color-mix(in srgb, var(--wash-1) 85%, transparent),
0 10px 30px -10px var(--wash-1-a85),
0 2px 8px -3px rgba(0, 0, 0, 0.35);
}
.plan:active {
@@ -829,7 +1013,7 @@ a.linkbtn {
font-weight: 500;
font-size: 0.8rem;
color: var(--muted);
margin-left: 7px;
margin-inline-start: 7px;
}
.plan-feats {
margin: 10px 0 14px;
@@ -902,39 +1086,45 @@ a.linkbtn {
.slug-field {
display: flex;
align-items: center;
padding-right: 16px;
border-radius: var(--radius);
padding-inline-end: 18px;
border-radius: 999px;
border: 1px solid var(--edge);
background: var(--surface);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
inset 0 1px 0 var(--ink-a4);
transition:
border-color 0.2s,
box-shadow 0.25s;
}
.slug-field .slug-input {
.slug-field .slug-input,
.slug-field .slug-input:focus {
flex: 1;
min-width: 0;
text-align: left;
text-align: start;
border: 0;
background: transparent;
/* The ring is the whole field's (focus-within below), not a second one inside it. */
box-shadow: none;
}
/* An email is typed left to right, but its Arabic placeholder reads from the right. */
:root[dir='rtl'] .field input.ltr-value:placeholder-shown {
direction: rtl;
}
.slug-field:focus-within {
border-color: var(--edge-strong);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
box-shadow: 0 0 0 4px var(--ink-a9);
}
.slug-field.bad {
border-color: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
box-shadow: 0 0 0 4px var(--danger-a16);
animation: shake 0.3s;
}
.slug-suffix {
flex: none;
color: var(--muted);
font-size: 0.95rem;
padding-left: 4px;
padding-inline-start: 4px;
user-select: none;
}
.hint {
@@ -976,3 +1166,139 @@ a.linkbtn {
border-color: var(--edge-strong);
border-top-color: var(--accent);
}
/* ── A large screen ──────────────────────────────────────────────────────────
On its side (an iPad, a foldable unfolded) the photo becomes a panel of its
own on the leading half, full height, and the column of choices stands
centred in the other half, the way a split sign-in page uses the width.
Upright, the photo band grows with the screen instead of stopping at a
phone's 440px. Logical sides, so Arabic mirrors it. */
@media (min-width: 840px) and (min-height: 600px) and (orientation: landscape) {
body::after,
:root[dir='rtl'] body::after {
inset: 16px auto 16px 16px;
inset-block: 16px;
inset-inline: 16px auto;
width: calc(50% - 24px);
height: auto;
border-radius: 28px;
background:
linear-gradient(180deg, transparent 50%, rgba(0, 0, 0, 0.32) 100%),
url('./img/kyoto.webp') 30% 45% / cover no-repeat;
-webkit-mask-image: none;
mask-image: none;
box-shadow: 0 30px 70px -30px rgba(0, 0, 0, 0.55);
}
body::before {
inset: 0;
inset-inline-start: 50%;
height: auto;
}
.shell {
max-width: none;
width: 50%;
margin-inline: 50% 0;
padding-inline: max(32px, calc(25vw - 220px));
}
}
/* With the whole half of a 13" iPad on its side, the plans stand two by two
rather than in a column to scroll through. */
@media (min-width: 1200px) and (min-height: 600px) and (orientation: landscape) {
.shell:has(.plans) {
padding-inline: max(32px, calc(25vw - 320px));
}
.plans {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* An iPad upright, a foldable unfolded, half of an iPad in Split View: the photo
spans the whole width as a band across the top and fades out only downwards,
and the choices stand under it in a wider column than a phone's, centred in
the room the band leaves, so the page reads as one composition rather than a
phone screen at the top of the glass. The plans take two columns. */
@media (min-width: 600px) and (min-height: 800px) and (orientation: portrait) {
body::before,
body::after {
height: 56vh;
}
body::after,
:root[dir='rtl'] body::after {
background-position:
0 0,
center 30%;
-webkit-mask-image: none;
mask-image: none;
}
.shell {
max-width: 560px;
padding-top: calc(env(safe-area-inset-top) + 28vh);
padding-bottom: calc(env(safe-area-inset-bottom) + 6vh);
}
.shell:has(.plans) {
max-width: 760px;
padding-top: calc(env(safe-area-inset-top) + 16vh);
}
.plans {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* ── A short screen on its side ──────────────────────────────────────────────
A phone, or a foldable folded, turned landscape: one column of choices is
taller than the glass, and the second choice or the main button ended up
below the edge. The two halves of a screen stand side by side instead, what
it is on the leading side and what to do on the other, top-aligned under the
status bar. The leading half stays put while a long list beside it (the
plans) scrolls, so Back is always in reach. */
@media (orientation: landscape) and (max-height: 500px) {
.shell {
max-width: 880px;
padding-top: calc(env(safe-area-inset-top) + 16px);
padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
padding-inline: max(28px, env(safe-area-inset-left)) max(28px, env(safe-area-inset-right));
}
.screen:has(> .col) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
column-gap: 40px;
align-items: center;
}
/* The plans run longer than the window: both halves start at the top. */
.screen:has(.plans) {
align-items: start;
}
.col {
display: flex;
flex-direction: column;
gap: 12px;
}
.col-about {
position: sticky;
top: calc(env(safe-area-inset-top) + 16px);
}
.hero-logo {
margin-bottom: 4px;
}
.hero-logo img {
height: 46px;
}
.title {
font-size: 1.75rem;
}
.btn {
min-height: 50px;
padding-block: 12px;
}
.features {
margin: 0;
}
.feature {
padding: 7px 0;
}
.foot-links {
margin-top: 0;
padding-top: 10px;
}
}
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "5.1.0",
"version": "5.1.2",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
@@ -53,9 +53,11 @@
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@capacitor/action-sheet": "^8.1.1",
"@capacitor/android": "^8.5.0",
"@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.0",
"@capacitor/dialog": "^8.0.1",
"@capacitor/filesystem": "^8.1.3",
"@capacitor/haptics": "^8.0.2",
"@capacitor/ios": "^8.5.0",
+7 -5
View File
@@ -1,6 +1,8 @@
import autoprefixer from 'autoprefixer';
import tailwindcss from 'tailwindcss';
import touchHover from './postcss/touchHover.js';
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
// touchHover last: it guards the :hover rules Tailwind has just written too.
plugins: [tailwindcss(), autoprefixer(), touchHover()],
};
+49
View File
@@ -0,0 +1,49 @@
/**
* Hover styles only while a pointer drives the UI.
*
* A finger has no hover, yet WebKit and Chrome apply :hover to whatever was
* tapped and keep it there until the next tap lands somewhere else. In the
* iOS and Android app that left buttons and rows tinted after every tap, a
* tell of a web page that no native app has. The app marks <html> with
* data-touch-input while a finger is in use (src/native/touchInput.ts), and
* this plugin makes every :hover rule, Tailwind's and our own, wait for that
* mark to be gone. A browser never sets it, so nothing changes there, and an
* iPad with a trackpad clears it on the first pointer move.
*
* The condition sits on the hovered element itself, wrapped in :where(), so a
* rule keeps its specificity and still wins or loses where it did before. A
* :hover after a pseudo-element (::-webkit-slider-thumb:hover) stays as it is:
* nothing may follow it there, and a rule with an invalid selector is dropped
* whole.
*/
export const TOUCH_INPUT_ATTR = 'data-touch-input';
const GUARD = `:where(:root:not([${TOUCH_INPUT_ATTR}]) *)`;
// :hover itself, not the escaped `hover\:` inside a Tailwind class name, and not :hover-something.
const HOVER = /(?<!\\):hover(?![\w-])/g;
const COMPOUND_START = /[\s>+~(,]/;
function compoundBefore(selector, index) {
let i = index - 1;
while (i >= 0 && !COMPOUND_START.test(selector[i])) i--;
return selector.slice(i + 1, index);
}
export function guardHover(selector) {
if (selector.includes(GUARD)) return selector;
return selector.replace(HOVER, (match, offset) =>
compoundBefore(selector, offset).includes('::') ? match : `${match}${GUARD}`
);
}
export default function touchHover() {
return {
postcssPlugin: 'trek-touch-hover',
Rule(rule) {
if (!rule.selector.includes(':hover')) return;
const next = rule.selectors.map(guardHover);
if (next.some((s, i) => s !== rule.selectors[i])) rule.selectors = next;
},
};
}
touchHover.postcss = true;
+2 -2
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 375,
"@typescript-eslint/no-misused-promises": 847,
"@typescript-eslint/no-misused-promises": 843,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1912,
"@typescript-eslint/no-non-null-assertion": 1911,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+31 -2
View File
@@ -282,11 +282,17 @@
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
{
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm, useExpenseLinks",
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts", "src/components/Budget/useExpenseLinks.ts"],
"feature": "Costs/budget ledger: useCostsLedger, useExpenseLinks",
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useExpenseLinks.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
{
"feature": "Costs/budget settle-up payment: useSettlementForm",
"hooks": ["src/components/Budget/useSettlementForm.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCostsPayment.tsx"]
},
{
"feature": "Create/edit trip form",
"hooks": ["src/components/Trips/tripFormModel.ts", "src/components/Trips/useTripCoverPicker.ts"],
@@ -663,6 +669,12 @@
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"]
},
{
"feature": "Road-trip Google Maps route import",
"hooks": ["src/components/Roadtrip/useGoogleRouteImport.ts"],
"desktop": ["src/components/Roadtrip/GoogleRouteImport.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtRouteImportSheet.tsx"]
},
{
"feature": "Road-trip stay length draft",
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
@@ -822,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"],
@@ -1002,12 +1020,15 @@
"src/mobile/screens/collections/MCollInvites.tsx": "Markup only; the invites and accept and decline come through props from useCollections via MCollections.",
"src/mobile/screens/collections/MCollLabelsSheet.tsx": "Phone label sheet; create, update, delete and assign come through props from useCollections.",
"src/mobile/screens/collections/MCollLinksEditor.tsx": "Markup only; link rows and the change handler come through props from the collection sheets.",
"src/mobile/screens/collections/MCollListNav.tsx": "Markup only; the collections' lists as the header dropdown or as the column beside the places on a wide window, with the lists and the selection handed in through props from useCollections.",
"src/mobile/screens/collections/MCollListPickerSheet.tsx": "Phone list picker sheet; the move or copy action comes through onPick from useCollections.",
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
"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.",
@@ -1018,6 +1039,9 @@
"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.",
"src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.",
"src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.",
@@ -1035,8 +1059,10 @@
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
"src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx": "Large screen form inside MRtInfoSheet over the planner's saveRoadtripLimit, the desktop dialog's own save, and the shared dayWindow, parseAvoid and rangeFromSpec.",
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
"src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx": "Large screen sheet over the planner's followTrack, the same hook RoadtripTrackModal runs on; the phone has no entry to it.",
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
@@ -1053,10 +1079,13 @@
"src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.",
"src/mobile/screens/trip/sheets/tourSheetOpen.ts": "Phone only context for MTourDetailShell: whether the tour sheet is open and the name it carries.",
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
"src/mobile/screens/trip/tabs/MFilesBoard.tsx": "Markup only; the trip's files as tiles and the picked file's preview on a wide window, with the files and their actions handed in through props from MFilesTab.",
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
"src/mobile/screens/trip/tabs/MReservationCardParts.tsx": "Markup only: the title, actions, date, files and delete sheet parts both phone booking tabs draw.",
"src/mobile/screens/trip/tabs/MTodoSidebar.tsx": "Markup only; the to-do filters as a pane beside the tasks on a wide window, with the filter state and the counts handed in through props from useTodoView.",
"src/mobile/screens/trip/tabs/MTripTabPanel.tsx": "Phone tab router that mounts the per tab screens and plugin frames, with no logic of its own.",
"src/mobile/screens/trip/tabs/collabPanes.ts": "Phone only; decides whether the collab tab shows the chat beside a board on a wide window, a layout question of the touch shell that the desktop planner does not have.",
"src/mobile/screens/trip/tabs/listsModel.ts": "Priority colour and label constants only the phone to-do list and task sheet use.",
"src/mobile/screens/trip/tabs/tabChrome.tsx": "Phone tab chrome primitives (scroller, section headers, fields, traveler chips, plugin slots).",
"src/mobile/screens/trip/tabs/tabModel.ts": "Phone tab prop type and status colour tokens only the phone tabs use.",
+2 -2
View File
@@ -2,7 +2,7 @@
"admin.ts": 112,
"atlas.ts": 9,
"backup.ts": 10,
"budget.ts": 36,
"budget.ts": 35,
"categories.ts": 1,
"collab.ts": 9,
"collection.ts": 35,
@@ -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,
+3 -7
View File
@@ -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,
@@ -79,15 +80,10 @@
"hooks/useAnchoredPosition.ts": 3,
"hooks/useElementRect.ts": 3,
"mobile/components/MSegmented.tsx": 1,
"mobile/components/MSheet.tsx": 1,
"mobile/screens/dashboard/MDashboard.tsx": 1,
"mobile/screens/journey/MJourneyDetail.tsx": 3,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
"mobile/screens/trip/MTripShell.tsx": 3,
"mobile/screens/trip/map/MMapArea.tsx": 3,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 1,
"mobile/screens/trip/tabs/MFileLightbox.tsx": 2,
"mobile/screens/vacay/MVacay.tsx": 1,
"mobile/screens/vacay/MVacayMonth.tsx": 1,
"pages/AtlasPage.tsx": 2,
"pages/JourneyDetailPage.tsx": 1,
+3 -3
View File
@@ -5,9 +5,9 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1214,
"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,
@@ -20,7 +20,7 @@
"src/pages/JourneyDetailPage.test.tsx": 4028,
"src/pages/LoginPage.tsx": 1347,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/atlas/useAtlas.ts": 1210,
"src/pages/atlas/useAtlas.ts": 1207,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 1918
+10 -9
View File
@@ -40,7 +40,7 @@
"components/Journey/JourneyDetailPageProviderPicker.tsx": 163,
"components/Journey/JourneyDetailPageScrollTrigger.tsx": 4,
"components/Journey/JourneyDetailPageVerdictSection.tsx": 51,
"components/Journey/JourneyEntryCover.tsx": 15,
"components/Journey/JourneyEntryCover.tsx": 13,
"components/Journey/JourneyMap.tsx": 3,
"components/Journey/JourneyMapGL.tsx": 5,
"components/Journey/MobileEntryView.tsx": 94,
@@ -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,
@@ -146,7 +147,7 @@
"mobile/components/MStorageTestResult.tsx": 3,
"mobile/components/MToastHost.tsx": 2,
"mobile/components/MUserPickerList.tsx": 1,
"mobile/screens/admin/MAdmin.tsx": 9,
"mobile/screens/admin/MAdmin.tsx": 8,
"mobile/screens/admin/MAdminAddonManager.tsx": 9,
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
@@ -181,9 +182,9 @@
"mobile/screens/collections/MCollShareSheet.tsx": 14,
"mobile/screens/collections/MCollSheetKit.tsx": 5,
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
"mobile/screens/collections/MCollections.tsx": 18,
"mobile/screens/collections/MCollections.tsx": 14,
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
"mobile/screens/dashboard/MDashboard.tsx": 31,
"mobile/screens/dashboard/MDashboard.tsx": 30,
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
"mobile/screens/dashboard/MUserMenu.tsx": 7,
"mobile/screens/journey/MJourney.tsx": 10,
@@ -202,7 +203,7 @@
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
"mobile/screens/settings/MSettings.tsx": 2,
"mobile/screens/settings/MSettings.tsx": 1,
"mobile/screens/settings/MSettingsAbout.tsx": 5,
"mobile/screens/settings/MSettingsAccount.tsx": 20,
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
@@ -214,7 +215,7 @@
"mobile/screens/settings/MSettingsUi.tsx": 11,
"mobile/screens/settings/MWebPushCard.tsx": 1,
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
"mobile/screens/trip/MTripShell.tsx": 9,
"mobile/screens/trip/MTripShell.tsx": 7,
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
@@ -262,7 +263,7 @@
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
"mobile/screens/trip/tabs/MCollabTab.tsx": 1,
"mobile/screens/trip/tabs/MCostsTab.tsx": 71,
"mobile/screens/trip/tabs/MCostsTab.tsx": 70,
"mobile/screens/trip/tabs/MDocSyncSheet.tsx": 43,
"mobile/screens/trip/tabs/MFileLightbox.tsx": 12,
"mobile/screens/trip/tabs/MFileLinkSheet.tsx": 2,
+4
View File
@@ -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
@@ -1,8 +1,8 @@
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-016: the trip chat logic behind the desktop
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-018: the trip chat logic behind the desktop
// Collab panel (quiet older-page errors, scroll on remote delete, upload progress) and the
// phone's chat tab (toasted older-page errors, sending gated on the edit permission).
import { act, renderHook, waitFor } from '@testing-library/react';
import type { ChangeEvent } from 'react';
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { createElement, type ChangeEvent } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { collabApi } from '../../api/client';
@@ -285,3 +285,80 @@ describe('useCollabChatData', () => {
expect(toast.error).toHaveBeenCalledWith('common.error');
});
});
describe('useCollabChatData on a box that changes size', () => {
// The observer reports when the test says the list's box has changed.
let report: () => void = () => {};
let observed: Element[] = [];
class Observer {
constructor(callback: ResizeObserverCallback) {
report = () => callback([], this as unknown as ResizeObserver);
}
observe(el: Element) {
observed.push(el);
}
unobserve() {}
disconnect() {}
}
/**
* The list as both views draw it: only once the messages are in, so at mount there
* is no element yet. Its sizes are the test's to set, since jsdom lays nothing out.
*/
function ChatList({ scrollHeight }: { scrollHeight: () => number }) {
const chat = useCollabChatData({
tripId: 1,
t,
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
});
const setList = (el: HTMLDivElement | null) => {
if (el && !Object.getOwnPropertyDescriptor(el, 'scrollHeight')) {
let scrollTop = 0;
Object.defineProperty(el, 'scrollHeight', { configurable: true, get: scrollHeight });
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => 400 });
Object.defineProperty(el, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (v: number) => {
scrollTop = v;
},
});
el.scrollTo = () => {};
}
(chat.scrollRef as { current: HTMLDivElement | null }).current = el;
};
if (chat.loading || chat.messages.length === 0) return createElement('p', null, 'loading');
return createElement('div', { 'data-testid': 'list', ref: setList, onScroll: chat.checkAtBottom });
}
async function mountList(height: number) {
let scrollHeight = height;
render(createElement(ChatList, { scrollHeight: () => scrollHeight }));
const list = await screen.findByTestId('list');
return { list, grow: (to: number) => (scrollHeight = to) };
}
beforeEach(() => {
observed = [];
});
afterEach(() => vi.unstubAllGlobals());
it('FE-COLLAB-CHATDATA-017: a list at its newest message stays there when a rotation or a fold resizes it', async () => {
vi.stubGlobal('ResizeObserver', Observer);
const { list, grow } = await mountList(400);
expect(observed).toContain(list);
grow(900);
report();
expect(list.scrollTop).toBe(900);
});
it('FE-COLLAB-CHATDATA-018: a list read further up keeps its place', async () => {
vi.stubGlobal('ResizeObserver', Observer);
const { list, grow } = await mountList(2000);
list.scrollTop = 200;
fireEvent.scroll(list);
grow(2600);
report();
expect(list.scrollTop).toBe(200);
});
});
@@ -77,6 +77,22 @@ export function useCollabChatData({
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
}, []);
// A list that sat at its newest message stays there when its box changes size (a
// rotation, a fold, a pane opening beside it): the messages wrap anew and the list
// grows, which left the newest ones below the composer. Both views draw the list
// only once there are messages to show, so the observer attaches when it appears,
// not at mount, when there is nothing yet to observe.
const listShown = !loading && messages.length > 0;
useEffect(() => {
const el = scrollRef.current;
if (!listShown || !el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
if (isAtBottom.current) el.scrollTop = el.scrollHeight;
});
observer.observe(el);
return () => observer.disconnect();
}, [listShown]);
useEffect(() => {
let cancelled = false;
setLoading(true);
@@ -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,
};
}
@@ -77,8 +77,9 @@ describe('JournalBody', () => {
const { container } = render(<JournalBody text={'# Big\n\n### Small'} />);
expect(container.querySelector('h1')).not.toBeInTheDocument();
expect(container.querySelector('h3')).not.toBeInTheDocument();
expect(screen.getByText('Big').tagName).toBe('P');
expect(screen.getByText('Small').tagName).toBe('P');
// Each paragraph's text sits in an isolate (<bdi>) so it keeps its own direction.
expect(screen.getByText('Big').parentElement?.tagName).toBe('P');
expect(screen.getByText('Small').parentElement?.tagName).toBe('P');
});
it('FE-COMP-JOURNALBODY-012: keeps a setext underline as text instead of a heading', () => {
@@ -15,13 +15,14 @@ export default function JournalBody({ text, dark }: Props) {
lineHeight: 1.6,
color: 'inherit',
}}>
{/* Each paragraph in its own direction (bdi): a Latin story in the Arabic UI keeps its full stop at its end. */}
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
blockquote: ({ children }) => (
<blockquote style={{
borderInlineStart: `3px solid var(--journal-accent)`,
@@ -1,4 +1,4 @@
// FE-JRN-COVER-001 to FE-JRN-COVER-010
// FE-JRN-COVER-001 to FE-JRN-COVER-011
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
@@ -133,4 +133,25 @@ describe('JourneyEntryCover', () => {
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
expect(container.querySelector('svg.lucide-play')).toBeNull()
})
it('FE-JRN-COVER-011: only the phone draws a card without a photo in the deep day colour, opaque even as a suggestion', () => {
const entry = buildEntry({ photos: [], type: 'skeleton' })
const phone = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} tone="mobile" />)
const phoneCard = phone.getByRole('button')
// d9, the deep wash (jsdom spells the colour out as rgba)
expect(phoneCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.85/)
expect(phoneCard.className).not.toContain('opacity-90')
expect(phone.container.querySelector('svg.lucide-map-pin')?.getAttribute('class')).toContain('-mt-6')
phone.unmount()
// The desktop and shared timelines keep their faint wash and the day-coloured pin.
const desk = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
const deskCard = desk.getByRole('button')
// 2e, the faint one
expect(deskCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.18/)
expect(deskCard.className).toContain('opacity-90')
const pin = desk.container.querySelector('svg.lucide-map-pin') as SVGElement
expect(pin.getAttribute('class')).not.toContain('-mt-6')
expect(pin.style.color).not.toBe('')
})
})
@@ -62,6 +62,12 @@ interface Props {
/** The public journey page serves photos through a share token. */
photoUrlFor?: (photoId: number) => string
tone?: 'app' | 'mobile'
/**
* large: the card a wide window shows (an iPad, a foldable unfolded), with
* the room for a two-line name and the whole place under it. compact: a phone
* on its side, where a regular card left the map a strip 30px tall.
*/
size?: 'compact' | 'regular' | 'large'
}
export default function JourneyEntryCover({
@@ -73,7 +79,10 @@ export default function JourneyEntryCover({
showWeather = true,
photoUrlFor,
tone = 'app',
size = 'regular',
}: Props) {
const large = size === 'large'
const box = large ? 'h-[196px] w-[176px]' : size === 'compact' ? 'h-[112px] w-[124px]' : 'h-[152px] w-[136px]'
const { t, locale } = useTranslation()
const isSuggestion = entry.type === 'skeleton'
@@ -90,7 +99,12 @@ export default function JourneyEntryCover({
const mood = showMood ? moodMeta(entry.mood) : undefined
const weather = showWeather ? weatherMeta(entry.weather) : undefined
const emptyGround = tone === 'mobile' ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800'
const mobile = tone === 'mobile'
const emptyGround = mobile ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800'
// The mark of a card without a photo: white and raised clear of the name on the
// phone's deep day colour, the day's own colour on the faint wash elsewhere.
const emptyIcon = mobile ? '-mt-6 opacity-80' : 'opacity-60'
const emptyIconStyle = mobile ? undefined : { color: dayColor }
// One ground for every corner mark. Dark rather than a white veil: these sit on
// whatever photograph the traveller took, and a bright sky swallowed the white one.
const badge = 'rounded-full bg-black/45 px-[7px] py-[2px] text-[10px] font-bold whitespace-nowrap text-white backdrop-blur-[2px]'
@@ -108,9 +122,11 @@ export default function JourneyEntryCover({
// growing its box moved every card after it once a swipe had landed, and the
// snap then pulled the strip again, which read as a jolt (carousels keep the
// room for it with their gap and top padding).
className={`relative h-[152px] w-[136px] flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
// White on the root: every mark and line on the card is white, over a photo or
// over the day's colour alike.
className={`relative text-white ${box} flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
isActive ? 'z-[1] scale-[1.18] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'scale-100 shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
} ${src ? '' : emptyGround} ${isSuggestion && !mobile ? 'opacity-90' : ''}`}
// backgroundImage, not background: the shorthand would drop the opaque colour
// the class supplies and leave the map showing through a card that is only a
// faint wash of the day's colour.
@@ -122,7 +138,14 @@ export default function JourneyEntryCover({
// ring lost its top edge to that clip.
style={{
boxShadow: `inset 0 0 0 ${isActive ? 2.5 : 1.5}px ${dayColor}${isActive ? '' : 'b3'}`,
...(src ? null : { backgroundImage: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }),
// Without a photograph the phone's card is the day's colour, deep enough to
// carry the white name like a photo does, rather than a grey that read as
// empty. Its ground stays opaque (no dimming for a suggestion either): it
// floats over the map, whose labels read through anything less. The desktop
// and shared timelines keep their faint wash of the day.
...(src ? null : { backgroundImage: mobile
? `linear-gradient(160deg, ${dayColor}d9, ${dayColor}8c 55%, ${dayColor}59)`
: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }),
}}
>
{src ? (
@@ -130,8 +153,8 @@ export default function JourneyEntryCover({
) : (
<span className="absolute inset-0 flex items-center justify-center">
{posterless
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={{ color: dayColor }} className="ml-0.5 opacity-60" />
: <MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />}
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={emptyIconStyle} className={`ml-0.5 ${emptyIcon}`} />
: <MapPin size={24} strokeWidth={1.8} style={emptyIconStyle} className={emptyIcon} />}
</span>
)}
@@ -155,7 +178,7 @@ export default function JourneyEntryCover({
italic, dimmed title carries the rest. */}
{isSuggestion && (
<span
className={`absolute top-2 ${entry.country_code ? 'start-[30px]' : 'start-2'} ${badgeDot} text-white`}
className={`absolute top-2 ${entry.country_code ? 'start-[30px]' : 'start-2'} ${badgeDot}`}
title={t('journey.entry.suggestion')}
aria-label={t('journey.entry.suggestion')}
>
@@ -172,7 +195,7 @@ export default function JourneyEntryCover({
</span>
)}
{weather && (
<span className={`${badgeDot} text-white`}>
<span className={`${badgeDot}`}>
<weather.icon size={11} strokeWidth={2.4} />
</span>
)}
@@ -181,11 +204,11 @@ export default function JourneyEntryCover({
{/* Bottom: the name gets the room the thumbnail layout never had. */}
<span className="absolute inset-x-0 bottom-0 flex flex-col gap-[1px] px-[10px] pb-[10px]">
<span className={`line-clamp-2 text-[13px] leading-[1.25] font-bold text-white drop-shadow-[0_1px_3px_rgba(0,0,0,.6)] ${isSuggestion ? 'italic' : ''}`}>
<span className={`line-clamp-2 text-[13px] leading-[1.25] font-bold drop-shadow-[0_1px_3px_rgba(0,0,0,.6)] ${isSuggestion ? 'italic' : ''}`}>
{title}
</span>
{place && (
<span className="truncate text-[10.5px] leading-[1.3] font-medium text-white/80 drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
<span className={`${large ? 'line-clamp-2' : 'truncate'} text-[10.5px] leading-[1.3] font-medium text-white/80 drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]`}>
{place}
</span>
)}
@@ -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}
/>
)}
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-062
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-063
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -592,6 +592,28 @@ describe('JourneyMap', () => {
}
});
it('FE-COMP-JOURNEYMAP-063: paddingStart keeps the journey clear of a rail on the leading side, either way round', () => {
const origRAF = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
try {
const { unmount } = render(<JourneyMap checkins={[]} entries={entriesWithCoords} paddingBottom={200} paddingStart={100} />);
expect(mockedMap().fitBounds).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ paddingTopLeft: [150, 50], paddingBottomRight: [50, 200] }),
);
unmount();
document.documentElement.dir = 'rtl';
render(<JourneyMap checkins={[]} entries={entriesWithCoords} paddingBottom={200} paddingStart={100} />);
expect(mockedMap().fitBounds).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ paddingTopLeft: [50, 50], paddingBottomRight: [150, 200] }),
);
} finally {
document.documentElement.dir = '';
globalThis.requestAnimationFrame = origRAF;
}
});
it('FE-COMP-JOURNEYMAP-026: dark mode picks the dark basemap', async () => {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} dark />);
// The default basemap is a vector style now, so it arrives as a style option
+22 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref, type RefObject } from 'react'
import { useEffect, useLayoutEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref, type RefObject } from 'react'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
@@ -76,6 +76,12 @@ interface Props {
*/
onMarkerCardClick?: (entryId: string) => void
paddingBottom?: number
/**
* How much of the map's leading edge a bar laid over it covers (the touch
* shell's rail), in px: framing keeps the journey clear of it. Read when the
* map frames, so a change alone does not rebuild the map.
*/
paddingStart?: number
/** CARTO key from the share payload: the public journey has no settings store to read. */
cartoApiKey?: string
/**
@@ -281,13 +287,23 @@ function markCoords(marks: JourneyMapMark[] | undefined): L.LatLngTuple[] {
return (marks ?? []).map(mark => [mark.lat, mark.lng] as L.LatLngTuple)
}
function fitOptions(paddingBottom: number | undefined): L.FitBoundsOptions {
return { paddingTopLeft: [50, 50], paddingBottomRight: [50, paddingBottom || 50], maxZoom: 16 }
function fitOptions(paddingBottom: number | undefined, paddingStart = 0): L.FitBoundsOptions {
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'
return {
paddingTopLeft: [50 + (rtl ? 0 : paddingStart), 50],
paddingBottomRight: [50 + (rtl ? paddingStart : 0), paddingBottom || 50],
maxZoom: 16,
}
}
function JourneyMap(
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) {
// The leading inset is read when the map frames, not a reason to rebuild it.
const paddingStartRef = useRef(paddingStart)
useLayoutEffect(() => {
paddingStartRef.current = paddingStart
})
// Read through a ref: the handler is fixed per surface, and putting it in the
// marker effect's deps would rebuild every marker for nothing.
const onMarkerCardClickRef = useRef(onMarkerCardClick)
@@ -536,7 +552,7 @@ function JourneyMap(
const pointCoords = [...allCoords, ...markCoords(marksRef.current)]
const fitCoords = pointCoords.length > 0 ? pointCoords : trackCoords
marksFramedRef.current = (marksRef.current?.length ?? 0) > 0
if (fitCoords.length > 0) map.fitBounds(L.latLngBounds(fitCoords), fitOptions(paddingBottom))
if (fitCoords.length > 0) map.fitBounds(L.latLngBounds(fitCoords), fitOptions(paddingBottom, paddingStartRef.current))
else map.setView([30, 0], 2)
} catch {}
})
@@ -652,7 +668,7 @@ function JourneyMap(
const coords = markCoords(marks)
if (!marksFramedRef.current && coords.length > 0) {
marksFramedRef.current = true
if (!readerMovedRef.current) map.fitBounds(L.latLngBounds([...entryCoordsRef.current, ...coords]), fitOptions(paddingBottom))
if (!readerMovedRef.current) map.fitBounds(L.latLngBounds([...entryCoordsRef.current, ...coords]), fitOptions(paddingBottom, paddingStartRef.current))
}
draw()
@@ -39,6 +39,12 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
paddingBottom?: number
/**
* How much of the map's leading edge a bar laid over it covers (the touch
* shell's rail), in px: framing keeps the journey clear of it. Read when the
* map frames, so a change alone does not rebuild the map.
*/
paddingStart?: number
/** Open the entry a marker card names when the card is tapped, both renderers. */
onMarkerCardClick?: (entryId: string) => void
/** Open an entry's photos from the marker card's thumbnail strip. GL renderer only. */
@@ -317,6 +317,14 @@ describe('JourneyMapGL', () => {
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'journey-route-line' }))
})
it('FE-COMP-JMAPGL-049: paddingStart widens the fit on the side of the rail', () => {
withToken()
render(<JourneyMapGL checkins={[]} entries={entries} paddingBottom={200} paddingStart={100} fullScreen />)
expect(gl.map.fitBounds).toHaveBeenCalledWith(gl.boundsList[0], expect.objectContaining({
padding: { top: 50, bottom: 200, left: 150, right: 50 },
}))
})
it('FE-COMP-JMAPGL-010: the camera fits entries and trail with the requested bottom padding', () => {
withToken()
render(
+18 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
import { useEffect, useLayoutEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
@@ -57,6 +57,12 @@ interface Props {
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
paddingBottom?: number
/**
* How much of the map's leading edge a bar laid over it covers (the touch
* shell's rail), in px: framing keeps the journey clear of it. Read when the
* map frames, so a change alone does not rebuild the map.
*/
paddingStart?: number
/** See the Leaflet twin (JRN-04): hidden layers go off the map in place. */
layerVisibility?: JourneyMapLayerVisibility
marks?: JourneyMapMark[]
@@ -185,9 +191,10 @@ function markBounds(gl: Props['gl'], marks: JourneyMapMark[], points: [number, n
return bounds
}
function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefined) {
function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefined, paddingStart = 0) {
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'
return {
padding: { top: 50, bottom: paddingBottom || 50, left: 50, right: 50 },
padding: { top: 50, bottom: paddingBottom || 50, left: 50 + (rtl ? 0 : paddingStart), right: 50 + (rtl ? paddingStart : 0) },
maxZoom: 16,
pitch: tilted ? 45 : 0,
duration: 0,
@@ -195,8 +202,13 @@ function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefin
}
function JourneyMapGL(
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) {
// The leading inset is read when the map frames, not a reason to rebuild it.
const paddingStartRef = useRef(paddingStart)
useLayoutEffect(() => {
paddingStartRef.current = paddingStart
})
const onMarkerCardClickRef = useRef(onMarkerCardClick)
onMarkerCardClickRef.current = onMarkerCardClick
const onMarkerPhotoClickRef = useRef(onMarkerPhotoClick)
@@ -526,7 +538,7 @@ function JourneyMapGL(
known.forEach(mark => bounds.extend([mark.lng, mark.lat]))
if (hasPoints || known.length > 0) {
try {
map.fitBounds(bounds, fitOptions(paddingBottom, enableMapbox3d && fullScreen))
map.fitBounds(bounds, fitOptions(paddingBottom, enableMapbox3d && fullScreen, paddingStartRef.current))
} catch { /* empty bounds */ }
}
})
@@ -631,7 +643,7 @@ function JourneyMapGL(
marksFramedRef.current = true
if (!readerMovedRef.current) {
try {
map.fitBounds(markBounds(gl, marks, entryPointsRef.current), fitOptions(paddingBottom, enableMapbox3d && fullScreen))
map.fitBounds(markBounds(gl, marks, entryPointsRef.current), fitOptions(paddingBottom, enableMapbox3d && fullScreen, paddingStartRef.current))
} catch { /* empty bounds */ }
}
}
@@ -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={[]}
@@ -1,4 +1,4 @@
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-009: the card carousel over the journey map,
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-010: the card carousel over the journey map,
// behind both the phone journey screen and the narrow desktop and public timeline.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -184,4 +184,33 @@ describe('useJourneyCarouselSync', () => {
settleAt(0);
expect(hook.result.current.activeIndex).toBe(0);
});
it('FE-JRN-CAROUSEL-010: a strip that comes back (the feed on a wide window gave way to it) is tracked again', () => {
const carousel = document.createElement('div');
carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
Object.defineProperty(carousel, 'clientWidth', { value: 500 });
let strip: HTMLDivElement | null = null;
const hook = renderHook(
(p: Options) => {
const sync = useJourneyCarouselSync(p);
(sync.carouselRef as { current: HTMLDivElement | null }).current = strip;
return sync;
},
{ initialProps: { entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn(), layoutKey: true } }
);
ENTRIES.forEach((_, i) => {
const node = document.createElement('div');
node.getBoundingClientRect = () => ({ left: i * 200, width: 100 }) as DOMRect;
hook.result.current.cardRefs.current.set(i, node);
});
// The strip mounts with the layout's turn, with the same entries as before.
strip = carousel;
hook.rerender({ entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn(), layoutKey: false });
carousel.dispatchEvent(new Event('scroll'));
act(() => {
vi.advanceTimersByTime(90);
});
expect(hook.result.current.activeIndex).toBe(1);
});
});
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import type { JourneyMapHandle } from './JourneyMap';
import { journeyDays } from './journeyCard';
@@ -17,6 +17,11 @@ export interface JourneyCarouselSyncOptions<E extends CarouselEntry> {
onOpenEntry: (entry: E) => void;
/** A tap on another card also moves the map to it (the phone screen). */
syncMapOnTap?: boolean;
/**
* Changes whenever the strip comes or goes (the phone screen shows a feed in its
* place on a wide window), so its scroll tracking attaches to the strip on show.
*/
layoutKey?: unknown;
}
/**
@@ -55,6 +60,56 @@ function nearestCardIndex(el: HTMLDivElement | null, cards: Map<number, HTMLDivE
*/
const SETTLE_MS = 90;
interface StripScrollOptions {
carouselRef: RefObject<HTMLDivElement | null>;
count: number;
nearestCard: () => number | null;
pickNearestCard: () => void;
onLive: (index: number | null) => void;
layoutKey: unknown;
}
/**
* Follows the strip's scrolling: the card under the centre while it moves, the
* active card once it settles. Every state update waits for the strip to come
* to rest, because updating the active card mid swipe resizes the cards and
* reflows the layout on every frame. Attaches again whenever `layoutKey`
* changes, since the strip may only now be on screen.
*/
function useStripScroll({ carouselRef, count, nearestCard, pickNearestCard, onLive, layoutKey }: StripScrollOptions) {
useEffect(() => {
const el = carouselRef.current;
if (!el || count === 0) return;
let settleTimer: number | null = null;
let frame: number | null = null;
const onScroll = () => {
if (frame == null) {
frame = window.requestAnimationFrame(() => {
frame = null;
onLive(nearestCard());
});
}
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
};
// Where the browser says when the snap has landed, the card grows right then
// instead of a beat later; the timer stays for the ones that do not.
const onScrollEnd = () => {
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = null;
pickNearestCard();
};
el.addEventListener('scroll', onScroll, { passive: true });
el.addEventListener('scrollend', onScrollEnd, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
el.removeEventListener('scrollend', onScrollEnd);
if (settleTimer != null) window.clearTimeout(settleTimer);
if (frame != null) window.cancelAnimationFrame(frame);
};
}, [carouselRef, count, pickNearestCard, nearestCard, onLive, layoutKey]);
}
/**
* The card carousel over the journey map, behind both the phone journey screen and the
* narrow desktop and public timeline: the active card is the one nearest the centre
@@ -66,6 +121,7 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
mapEntries,
onOpenEntry,
syncMapOnTap = false,
layoutKey,
}: JourneyCarouselSyncOptions<E>) {
const mapRef = useRef<JourneyMapHandle>(null);
const carouselRef = useRef<HTMLDivElement>(null);
@@ -106,39 +162,7 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
});
}, [nearestCard, syncMapToCard]);
// Defer every state update until scrolling settles: updating the active card
// mid swipe resizes the cards and reflows the layout on every frame.
useEffect(() => {
const el = carouselRef.current;
if (!el || entries.length === 0) return;
let settleTimer: number | null = null;
let frame: number | null = null;
const onScroll = () => {
if (frame == null) {
frame = window.requestAnimationFrame(() => {
frame = null;
setLiveIndex(nearestCard());
});
}
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
};
// Where the browser says when the snap has landed, the card grows right then
// instead of a beat later; the timer stays for the ones that do not.
const onScrollEnd = () => {
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = null;
pickNearestCard();
};
el.addEventListener('scroll', onScroll, { passive: true });
el.addEventListener('scrollend', onScrollEnd, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
el.removeEventListener('scrollend', onScrollEnd);
if (settleTimer != null) window.clearTimeout(settleTimer);
if (frame != null) window.cancelAnimationFrame(frame);
};
}, [entries.length, pickNearestCard, nearestCard]);
useStripScroll({ carouselRef, count: entries.length, nearestCard, pickNearestCard, onLive: setLiveIndex, layoutKey });
const scrollCardIntoCenter = useCallback((idx: number) => {
cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
@@ -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,6 +14,7 @@ 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';
@@ -229,8 +230,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());
+1 -1
View File
@@ -123,7 +123,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
}} data-desktop-navbar="" className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
the tab pill between them sits in the middle of the bar rather than
being centred on top of both (#1983). Both columns keep at least their
+16 -11
View File
@@ -31,18 +31,10 @@ export function useCreateAction(phone: boolean): CreateAction {
const inCollection = useMatch('/collections/:id');
if (inTrip) {
// The "+" is context-aware per active tab: Bookings → reservation,
// Transports → transport, Costs → expense. Tabs without a create modal
// (lists / files / collab) fall through to adding a place. #1349
const id = inTrip.params.id;
const id = inTrip.params.id ?? '';
const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${id}`) : null;
if (tripTab === 'finanzplan')
return { label: t('costs.addExpense'), run: () => navigate(`/trips/${id}?create=expense`) };
if (tripTab === 'buchungen')
return { label: t('reservations.addManual'), run: () => navigate(`/trips/${id}?create=reservation`) };
if (tripTab === 'transports')
return { label: t('transport.addManual'), run: () => navigate(`/trips/${id}?create=transport`) };
return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) };
const intent = tripCreateIntent(id, tripTab);
return { label: t(intent.labelKey), run: () => navigate(intent.to) };
}
if (inJourney) {
// Context-aware per tab, like the trip's "+": the Gallery holds photos, so
@@ -69,6 +61,19 @@ export function useCreateAction(phone: boolean): CreateAction {
return { label: t('dashboard.newTrip'), run: () => navigate('/dashboard?create=1') };
}
/**
* What the "+" creates inside a trip, by its active tab: Bookings a reservation,
* Transports a transport, Costs an expense. Tabs without a create modal (lists,
* files, collab) fall through to adding a place (#1349). Pure, so a caller that
* knows the tab itself (the trip's own ⌘N, MTripShell) asks with that one.
*/
export function tripCreateIntent(tripId: string, tab: string | null): { labelKey: string; to: string } {
if (tab === 'finanzplan') return { labelKey: 'costs.addExpense', to: `/trips/${tripId}?create=expense` };
if (tab === 'buchungen') return { labelKey: 'reservations.addManual', to: `/trips/${tripId}?create=reservation` };
if (tab === 'transports') return { labelKey: 'transport.addManual', to: `/trips/${tripId}?create=transport` };
return { labelKey: 'places.addPlace', to: `/trips/${tripId}?create=place` };
}
/** Whether a nav item is the current route: the dashboard only on its exact path, the rest by prefix. */
export function isNavItemActive(pathname: string, to: string): boolean {
return to === '/dashboard' ? pathname === '/dashboard' : pathname.startsWith(to);
@@ -0,0 +1,110 @@
import { render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
// FE-MAP-RESIZE-001 onwards
const map = vi.hoisted(() => ({
container: {} as HTMLElement,
invalidateSize: vi.fn(),
getContainer: vi.fn(),
getCenter: vi.fn(() => ({ lat: 35, lng: 135.7 })),
getZoom: vi.fn(() => 12),
getSize: vi.fn(() => ({ x: 1376, y: 1032 })),
setView: vi.fn(),
whenReady: vi.fn((fn: () => void) => fn()),
on: vi.fn(),
off: vi.fn(),
}));
vi.mock('react-leaflet', () => ({ useMap: () => map }));
vi.mock('./lngFrameContext', () => ({ useLngFrame: () => null }));
import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport';
import ContainerResizeWatcher, { MapCameraSnapshot, OpeningFrameFit } from './ContainerResizeWatcher';
import type { OpeningFrame } from './openingView';
const realObserver = globalThis.ResizeObserver;
describe('ContainerResizeWatcher', () => {
afterEach(() => {
globalThis.ResizeObserver = realObserver;
vi.clearAllMocks();
});
it('FE-MAP-RESIZE-001: the map measures itself again whenever its box changes size', () => {
const element = document.createElement('div');
map.getContainer.mockReturnValue(element);
let fire: () => void = () => {};
const observe = vi.fn();
const disconnect = vi.fn();
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
fire = cb;
}
observe = observe;
unobserve = vi.fn();
disconnect = disconnect;
} as unknown as typeof ResizeObserver;
const { unmount } = render(<ContainerResizeWatcher />);
expect(observe).toHaveBeenCalledWith(element);
fire();
expect(map.invalidateSize).toHaveBeenCalledWith();
unmount();
expect(disconnect).toHaveBeenCalled();
});
it('FE-MAP-RESIZE-002: the camera snapshot reports where the map looks and stops on unmount', () => {
const onChange = vi.fn();
const { unmount } = render(<MapCameraSnapshot onChange={onChange} />);
expect(onChange).toHaveBeenCalledWith([35, 135.7], 12);
expect(map.on).toHaveBeenCalledWith('moveend zoomend', expect.any(Function));
unmount();
expect(map.off).toHaveBeenCalledWith('moveend zoomend', expect.any(Function));
});
});
describe('OpeningFrameFit', () => {
// A day across Kyoto from Arashiyama in the west to Ginkaku-ji in the east, wider than tall.
const points = [
{ lat: 35.0094, lng: 135.6668 },
{ lat: 35.005, lng: 135.7649 },
{ lat: 35.0037, lng: 135.7788 },
{ lat: 35.027, lng: 135.7982 },
];
const padding = { top: 20, right: 40, bottom: 20, left: 40 };
const windowFrame = (): OpeningFrame => {
const view = computeMapViewport(points, { tileSize: TILE_SIZE_RASTER, padding, width: 1376, height: 1032 });
if (!view) throw new Error('the day has places to frame');
return { points, padding, wrapsWorld: false, center: view.center, zoom: view.zoom, width: 1376, height: 1032 };
};
afterEach(() => vi.clearAllMocks());
it('FE-MAP-RESIZE-004: a map beside the plan, narrower than the window, frames the day again for its own size', () => {
const frame = windowFrame();
map.getSize.mockReturnValue({ x: 824, y: 1032 });
map.getZoom.mockReturnValue(frame.zoom);
map.getCenter.mockReturnValue({ lat: frame.center[0], lng: frame.center[1] });
render(<OpeningFrameFit frame={frame} />);
expect(map.setView).toHaveBeenCalledOnce();
const [, zoom, options] = map.setView.mock.calls[0];
// Further out than the window's frame, so the whole day fits the narrower map.
expect(zoom).toBeLessThan(frame.zoom);
expect(options).toEqual({ animate: false });
});
it('FE-MAP-RESIZE-005: a map the size of the window, or one that opened at a kept camera, stays where it is', () => {
const frame = windowFrame();
map.getSize.mockReturnValue({ x: 1376, y: 1000 });
map.getZoom.mockReturnValue(frame.zoom);
map.getCenter.mockReturnValue({ lat: frame.center[0], lng: frame.center[1] });
const { unmount } = render(<OpeningFrameFit frame={frame} />);
expect(map.setView).not.toHaveBeenCalled();
unmount();
map.getSize.mockReturnValue({ x: 824, y: 1032 });
map.getZoom.mockReturnValue(9);
render(<OpeningFrameFit frame={frame} />);
expect(map.setView).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,81 @@
import { useEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport';
import { useLngFrame } from './lngFrameContext';
import type { OpeningFrame } from './openingView';
/**
* Leaflet measures its container on window resizes only. The touch shell on a
* wide window resizes the map without one: the trip's plan pane opens and
* closes beside it, and a foldable folds. Without a fresh measure the map kept
* its old size, with grey bands or tiles cut off at the old edge.
*/
export default function ContainerResizeWatcher() {
const map = useMap();
useEffect(() => {
if (typeof ResizeObserver === 'undefined') return;
// Leaflet's default keeps the centre where it was, so what the map framed stays
// in the middle of the room it now has.
const observer = new ResizeObserver(() => map.invalidateSize());
observer.observe(map.getContainer());
return () => observer.disconnect();
}, [map]);
return null;
}
/** Reports where the map looks after every move, so a remount can come back to it. */
export function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
const map = useMap();
useEffect(() => {
const save = () => {
const center = map.getCenter();
onChange([center.lat, center.lng], map.getZoom());
};
map.whenReady(save);
map.on('moveend zoomend', save);
return () => {
map.off('moveend zoomend', save);
};
}, [map, onChange]);
return null;
}
/**
* The map opens framed on the trip's places, a frame worked out before the
* map exists and so for the window's size. Where the map is much smaller
* than the window (the plan beside it on an iPad or an unfolded foldable),
* that frame is too close and the day runs off the map. Once the map knows
* its size, the frame is worked out again for it. A map that opened anywhere
* else (a camera kept from before a remount) is left where it is.
*/
export function OpeningFrameFit({ frame }: { frame: OpeningFrame }) {
const map = useMap();
const lngFrame = useLngFrame();
const done = useRef(false);
useEffect(() => {
if (done.current) return;
done.current = true;
const { x, y } = map.getSize();
if (x <= 0 || y <= 0) return;
if (Math.abs(x - frame.width) <= frame.width * 0.1 && Math.abs(y - frame.height) <= frame.height * 0.1) return;
const at = map.getCenter();
const opened =
Math.abs(map.getZoom() - frame.zoom) < 0.01 &&
Math.abs(at.lat - frame.center[0]) < 1e-6 &&
Math.abs(at.lng - frame.center[1]) < 1e-6;
if (!opened) return;
const view = computeMapViewport(frame.points, {
tileSize: TILE_SIZE_RASTER,
padding: frame.padding,
wrapsWorld: frame.wrapsWorld,
width: x,
height: y,
});
// A whole zoom level, as Leaflet snaps to them, and the one that fits rather than the nearest.
if (view)
map.setView([view.center[0], frameLng(lngFrame, view.center[1])], Math.floor(view.zoom), { animate: false });
}, [map, lngFrame, frame]);
return null;
}
@@ -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')
})
})
+36 -5
View File
@@ -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} />)}
+52 -1
View File
@@ -4,6 +4,7 @@ import { render, screen } from '../../../tests/helpers/render'
import { act, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { resetAllStores } from '../../../tests/helpers/store'
import { setSizeClass } from '../../../tests/helpers/sizeClass'
import { buildPlace, buildReservation } from '../../../tests/helpers/factories'
import { AMAP_ROAD, AMAP_SATELLITE, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL } from '../../constants/mapDefaults'
import { useAuthStore } from '../../store/authStore'
@@ -16,6 +17,8 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
const mapMock = vi.hoisted(() => ({
getContainer: vi.fn(() => document.createElement('div')),
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
// The map fills the window here, so the opening frame stands (OpeningFrameFit).
getSize: vi.fn(() => ({ x: window.innerWidth, y: window.innerHeight })),
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
@@ -204,6 +207,21 @@ vi.mock('../../services/photoService', () => ({
getAllThumbs: vi.fn(() => ({})),
}))
// The app shows the touch shell at every size; a test can say it does (undefined: as in a browser).
const shell = vi.hoisted(() => ({ phone: undefined as boolean | undefined }))
vi.mock('../../mobile/useIsPhone', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../mobile/useIsPhone')>()
// The real hook runs every time, so a test that changes the answer never changes the
// number of hooks a mounted map has (React throws on that).
return {
...actual,
useIsPhone: () => {
const real = actual.useIsPhone()
return shell.phone ?? real
},
}
})
vi.mock('./gcj02Crs', () => ({
crsForBasemap: (gcj02: boolean) => gcj02 ? { code: 'TREK:GCJ02' } : undefined,
}))
@@ -227,6 +245,7 @@ const ORIGINAL_WIDTH = window.innerWidth
afterEach(() => {
vi.clearAllMocks()
shell.phone = undefined
resetAllStores()
clusterMock.markers.clear()
clusterMock.asked.length = 0
@@ -1296,7 +1315,8 @@ describe('MapView selection panning (#921)', () => {
afterEach(() => {
delete projectable.project
delete projectable.unproject
delete projectable.getSize
// Back to the map that fills the window (the mock's own size).
projectable.getSize = vi.fn(() => ({ x: window.innerWidth, y: window.innerHeight }))
})
it('FE-COMP-MAPVIEW-090: pans the pin back out once the card measured taller than the allowance', () => {
@@ -1574,6 +1594,37 @@ describe('MapView bounds fitting', () => {
expect(mapMock.fitBounds.mock.calls[2][1]).toMatchObject({ paddingTopLeft: [40, 20], paddingBottomRight: [40, 20] })
})
it('FE-COMP-MAPVIEW-094: beside the pane the touch shell opens at the whole zoom level that fits; the desktop keeps its own opening', () => {
// A day spread so that its exact fit lies between two levels.
const places = [buildMapPlace({ id: 1, lat: 34.9671, lng: 135.7727 }), buildMapPlace({ id: 2, lat: 35.005, lng: 135.7649 })]
shell.phone = true
setSizeClass('wide')
const { unmount } = render(<MapView places={places} />)
const paned = Number(screen.getByTestId('map-container').getAttribute('data-zoom'))
expect(Number.isInteger(paned)).toBe(true)
unmount()
// The desktop planner hands Leaflet the fitted level, which it rounds to the nearest
// as it always has, and fits no second time.
shell.phone = false
setSizeClass('compact')
mapMock.setView.mockClear()
render(<MapView places={places} />)
const desk = Number(screen.getByTestId('map-container').getAttribute('data-zoom'))
expect(Number.isInteger(desk)).toBe(false)
expect(mapMock.setView).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-093: on an iPad the touch shell frames a day clear of its floating toolbar and corner buttons', () => {
shell.phone = true
setSizeClass('wide')
const places = [buildMapPlace({ id: 1, lat: 35, lng: 135.7 }), buildMapPlace({ id: 2, lat: 35.04, lng: 135.78 })]
const { rerender } = render(<MapView places={places} fitKey={1} />)
rerender(<MapView places={places} fitKey={2} />)
const opts = mapMock.fitBounds.mock.calls[mapMock.fitBounds.mock.calls.length - 1]?.[1]
expect(opts).toMatchObject({ paddingTopLeft: [40, 84], paddingBottomRight: [40, 56] })
})
it('FE-COMP-MAPVIEW-079: the caller padding is read by value, so only new numbers refit the points on screen', () => {
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const { rerender } = render(
+24 -35
View File
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState, useMemo, useCallback, memo, type Component
import DOM from 'react-dom'
import { categoryIconSvg } from './categoryIconSvg'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import ContainerResizeWatcher, { MapCameraSnapshot, OpeningFrameFit } from './ContainerResizeWatcher'
import { openingView } from './openingView'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
@@ -71,9 +73,10 @@ 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 { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
import type { ViewportPadding } from '../../utils/mapViewport'
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
import type { MapViewProps } from './mapViewContract'
@@ -244,20 +247,6 @@ function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { cur
return null
}
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
const map = useMap()
useEffect(() => {
const save = () => {
const center = map.getCenter()
onChange([center.lat, center.lng], map.getZoom())
}
map.whenReady(save)
map.on('moveend zoomend', save)
return () => { map.off('moveend zoomend', save) }
}, [map, onChange])
return null
}
interface MapControllerProps {
center: [number, number]
zoom: number
@@ -421,6 +410,7 @@ import { markerPhotoHtml, placePhotoFull, placePhotoUrl } from './placePhoto'
import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useIsWide } from '../../hooks/sizeClass'
// Live-location rendering inside the Leaflet map. Subscribes via the
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
@@ -683,40 +673,37 @@ export const MapView = memo(function MapView({
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations)
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
// The chrome overlaying the map (side panels, day detail). Kept as a plain box so both the
// Leaflet fit options and the opening-camera maths can read the same numbers.
// Followed rather than read once: a phone turned sideways and back crosses
// the breakpoint twice, and the padding here and the locate button further
// down used to stay with whatever the map saw when it mounted.
const isMobile = useIsPhone()
// The touch shell on an iPad or an unfolded foldable floats its toolbar over the
// top of the map and its buttons in the corners, and its pins stand above their
// spot: a day framed to the phone's thin margin ran under the toolbar.
const isWide = useIsWide()
const paddingBox = useMemo((): ViewportPadding => {
if (isMobile) return { top: 20, right: 40, bottom: 20, left: 40 }
if (isMobile) {
const margin = { top: 20, right: 40, bottom: 20, left: 40 }
return isWide ? { ...margin, top: 84, bottom: 56 } : margin
}
return {
top: 60,
right: rightWidth + 40,
bottom: bottomChromePadding({ hasInspector, inspectorHeight, hasDayDetail }),
left: leftWidth + 40,
}
}, [isMobile, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
}, [isMobile, isWide, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
const paddingOpts = useMemo(() => leafletPadding(paddingBox), [paddingBox])
// Open framed on the places rather than on the caller's default, so a trip in Japan shows
// Japan straight away instead of the world view followed by a flight across the planet.
// The initializer runs once, at mount — exactly when this should be decided; afterwards the
// camera belongs to the user. `framed` is false when no place has coordinates (a new trip),
// and then the caller's center/zoom stands.
const [initialView] = useState(() => {
const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, {
tileSize: TILE_SIZE_RASTER,
padding: paddingBox,
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
wrapsWorld: lngFrame !== null,
})
const opening: [number, number] | undefined = framed ? [framed.center[0], frameLng(lngFrame, framed.center[1])] : undefined
return { center: opening ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
})
// Open framed on the places, not on the caller's default: a trip in Japan shows Japan at once instead
// of the world and a flight across it. Decided once, at mount; afterwards the camera is the user's.
// `framed` is false when no place has coordinates (a new trip), and then the caller's center/zoom stands.
const [initialView] = useState(() => openingView(dayPlaces.length > 0 ? dayPlaces : places, {
padding: paddingBox, lngFrame, center, zoom, paned: isMobile && isWide,
}))
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
cameraSnapshotRef.current = { center: cameraCenter, zoom: cameraZoom }
@@ -952,6 +939,8 @@ export const MapView = memo(function MapView({
{/* First, so a fit or a camera request in the same commit starts from the trip's world copy. */}
<LngFrameFollower />
<MapController center={center} zoom={zoom} />
<ContainerResizeWatcher />
{initialView.refit && <OpeningFrameFit frame={initialView.refit} />}
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingBox={paddingBox} />
@@ -1138,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}
@@ -40,7 +40,10 @@ vi.mock('../../hooks/useNetworkMode', () => ({
useNetworkMode: () => ({ offline: env.offline, forced: false, setForced: () => {} }),
}));
vi.mock('../../hooks/useIsDark', () => ({ useIsDark: () => env.dark }));
vi.mock('../../native/tileCache', () => ({ tileCacheActive: () => env.tileStore }));
vi.mock('../../native/tileCache', () => ({
tileCacheActive: () => env.tileStore,
glTileStoreActive: () => env.tileStore,
}));
vi.mock('../../native/platform', async (importOriginal) => ({
...(await importOriginal<typeof import('../../native/platform')>()),
isNativeIos: () => false,

Some files were not shown because too many files have changed in this diff Show More