From afaacec61a81305a1ee163b2d9cc8fa586de8cd2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 8 Nov 2026 10:15:20 +0100 Subject: [PATCH] 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. --- client/ios/App/App/TrekWatchLink.swift | 98 ++++++++++++++++++- client/ios/App/TREKWatch/WatchModel.swift | 22 ++++- .../components/Layout/NativeWidgetsSync.tsx | 4 + client/src/native/watchListChanges.test.ts | 71 ++++++++++++++ client/src/native/watchListChanges.ts | 42 ++++++++ 5 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 client/src/native/watchListChanges.test.ts create mode 100644 client/src/native/watchListChanges.ts diff --git a/client/ios/App/App/TrekWatchLink.swift b/client/ios/App/App/TrekWatchLink.swift index 87d5e0228..d542e54e8 100644 --- a/client/ios/App/App/TrekWatchLink.swift +++ b/client/ios/App/App/TrekWatchLink.swift @@ -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); @@ -110,6 +111,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 +165,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 +184,72 @@ 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 = 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() + } + } + WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in + guard let host = request.url?.host else { return finish(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 finish(false) } + var signed = request + for (field, value) in HTTPCookie.requestHeaderFields(with: session) { + signed.setValue(value, forHTTPHeaderField: field) + } + URLSession.shared.dataTask(with: signed) { _, response, error in + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + finish(error == nil && (200..<300).contains(status)) + }.resume() + } + } + + /// 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 + } +} diff --git a/client/ios/App/TREKWatch/WatchModel.swift b/client/ios/App/TREKWatch/WatchModel.swift index e48f976ee..835013874 100644 --- a/client/ios/App/TREKWatch/WatchModel.swift +++ b/client/ios/App/TREKWatch/WatchModel.swift @@ -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). diff --git a/client/src/components/Layout/NativeWidgetsSync.tsx b/client/src/components/Layout/NativeWidgetsSync.tsx index 742281fa6..0252a2800 100644 --- a/client/src/components/Layout/NativeWidgetsSync.tsx +++ b/client/src/components/Layout/NativeWidgetsSync.tsx @@ -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()); diff --git a/client/src/native/watchListChanges.test.ts b/client/src/native/watchListChanges.test.ts new file mode 100644 index 000000000..15cee9489 --- /dev/null +++ b/client/src/native/watchListChanges.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { offlineDb } from '../db/offlineDb'; +import type { PackingItem, TodoItem } from '../types'; +import { onWatchListsChange } from './watchListChanges'; + +// FE-NATIVE-WATCHSYNC-001 onwards: the watch hears of a list change at once, not on the next refresh. + +const packing = (id: number, checked: number) => + ({ id, trip_id: 1, name: `Item ${id}`, checked }) as unknown as PackingItem; +const todo = (id: number, checked: number) => ({ id, trip_id: 1, name: `Task ${id}`, checked }) as unknown as TodoItem; + +/** Long enough for the live query to answer and the settle window to pass. */ +const settled = () => new Promise((resolve) => setTimeout(resolve, 120)); + +describe('onWatchListsChange', () => { + let stop: (() => void) | null = null; + + beforeEach(async () => { + await offlineDb.packingItems.clear(); + await offlineDb.todoItems.clear(); + await offlineDb.packingItems.put(packing(1, 0)); + await offlineDb.todoItems.put(todo(2, 0)); + }); + + afterEach(() => { + stop?.(); + stop = null; + }); + + it('FE-NATIVE-WATCHSYNC-001: subscribing is no change, a tick on the packing list is', async () => { + const run = vi.fn(); + stop = onWatchListsChange(run, 20); + await settled(); + expect(run).not.toHaveBeenCalled(); + + await offlineDb.packingItems.put(packing(1, 1)); + await settled(); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('FE-NATIVE-WATCHSYNC-002: a to-do ticked, here or by another member, counts as well', async () => { + const run = vi.fn(); + stop = onWatchListsChange(run, 20); + await settled(); + await offlineDb.todoItems.put(todo(2, 1)); + await settled(); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('FE-NATIVE-WATCHSYNC-003: a run of ticks settles into one refresh', async () => { + const run = vi.fn(); + stop = onWatchListsChange(run, 60); + await settled(); + await offlineDb.packingItems.put(packing(1, 1)); + await offlineDb.packingItems.put(packing(3, 0)); + await offlineDb.todoItems.put(todo(2, 1)); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('FE-NATIVE-WATCHSYNC-004: after the unsubscribe nothing is called any more', async () => { + const run = vi.fn(); + stop = onWatchListsChange(run, 20); + await settled(); + stop(); + stop = null; + await offlineDb.packingItems.put(packing(1, 1)); + await settled(); + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/native/watchListChanges.ts b/client/src/native/watchListChanges.ts new file mode 100644 index 000000000..f4f86f763 --- /dev/null +++ b/client/src/native/watchListChanges.ts @@ -0,0 +1,42 @@ +import { liveQuery } from 'dexie'; +import { offlineDb } from '../db/offlineDb'; + +/** Long enough to take a run of ticks (or a synced batch) as one change, short enough to read as instant. */ +const SETTLE_MS = 300; + +/** + * Calls `run` once the packing lists or the to-dos this device keeps offline + * have changed, after they settled: a tick in the app, a tick from the watch + * written through, or another member's tick arriving over the WebSocket (the + * remote event handler writes it to the same tables). The watch's snapshot is + * built from those tables, so this is what keeps the watch as current as the + * phone instead of waiting for the next quarter-hour refresh. + * + * The first answer of the query is the state at subscription, not a change, and + * is skipped. Returns the unsubscribe. + */ +export function onWatchListsChange(run: () => void, settleMs = SETTLE_MS): () => void { + let first = true; + let timer: ReturnType | null = null; + const subscription = liveQuery(() => + Promise.all([offlineDb.packingItems.toArray(), offlineDb.todoItems.toArray()]) + ).subscribe({ + next: () => { + if (first) { + first = false; + return; + } + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + run(); + }, settleMs); + }, + // A failed read changes nothing on the watch; the next write tries again. + error: () => undefined, + }); + return () => { + if (timer) clearTimeout(timer); + subscription.unsubscribe(); + }; +}