Skip to content

feat(push): open a tap on the saved connection that sent it - #192

Open
objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/issue-174-push-per-connection
Open

objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/issue-174-push-per-connection

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #174

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:点开推送时,App 按推送数据里的服务器来源(origin)找到对应的已保存连接;来自当前连接的照旧打开,来自另一个已保存连接的先切换过去,等它的 Console 加载好再打开页面。
  • 为什么改:所有已保存的服务器共用同一个设备推送令牌,以前每条推送都在当前连接上打开,服务器 B 的通知会在 A 上打开错误的页面。
  • 风险与回滚:老版本服务器的推送不带 origin,保存了两个及以上连接时点开不再打开页面(只保存一个时照旧);回滚即还原本 PR,纯客户端改动,无数据迁移。
  • 先看:hooks/usePushRouting.ts(openTap:先切换、确认已切换、再路由)、lib/push-notifications.ts(pushTapTarget:四条规则与 MDM 锁)、lib/connections/model.ts(connectionByOrigin:逐字节比较)。
  • 两分钟验证:npx jest --ci __tests__/hooks/usePushRouting.test.ts __tests__/components/ConsoleScreen.test.tsx,预期 36 个测试全部通过。
  • 需要你做的:真机核验(未做,本 PR 不声称已验证):保存两台服务器并都注册推送,点开非当前那台发来的推送,应在它自己的服务器上打开;再删除一台服务器,点它以前的推送,应什么都不打开。

What changes

The platform's push data now names its sender: origin, the origin the Console was loaded from when it registered the device (objectstack PushDeliveryDataSchema.origin, rule in packages/spec/src/system/push-server-origin.ts). The shell uses it to pick the saved connection a tap opens on.

  • lib/connections/model.ts: connectionByOrigin(list, origin) compares origin byte for byte with originOf(connection.serverUrl). The stored server URL is normalized (lower-cased host, scheme default port dropped, scheme kept), so its origin is exactly what its Console reads as window.location.origin. No case folding, port guessing or host-only match; a non-string matches nothing.
  • lib/push-notifications.ts: pushSenderFromResponse reads origin from the same dictionaries, in the same order, as the route. pushTapTarget applies the rules:
    1. origin is the current connection's: route there, as before.
    2. origin is another saved connection's: that connection (the hook switches first).
    3. origin present, no saved connection has it: drop.
    4. no origin: the only saved connection; with two or more, drop.
      Under an MDM lock (lockServer), only the managed connection is open to a tap; any other is dropped, never switched to (and while the managed address waits for its confirm step, every tap drops). routeFromPushData is unchanged; routeFromResponse only had its candidate list factored out.
  • hooks/usePushRouting.ts: the hook takes only onRoute and reads the stores. Each tap (listener or cold-start response) is deduped by identifier at intake, as before, then held until the saved connections are loaded and the app lock is open. Then: pick the connection, compute the route against that connection's server URL, switch with the store's select (the switcher's path) when it is not current, check the switch stuck, and only then call onRoute. Only the latest held tap is opened.
  • app/_layout.tsx: the call site drops the serverUrl argument. This file is outside the claim's file surface: measurement showed the hook is mounted here, not in app/console.tsx, and app/console.tsx needed no change (see Acceptance notes).
  • Docs: docs/PUSH-NOTIFICATIONS.md gains "Which server a tap opens on" and the origin key in both payload samples; docs/HYBRID-SHELL.md replaces the "Push taps carry no connection" known limit with the one that remains (an older server, two or more saved connections) and notes the push switch under "Switching" and "Managed deployment".
  • .changeset/174-push-per-connection.md (patch).

Tests

  • __tests__/hooks/usePushRouting.test.ts (rewritten for the new signature, 14 hook cases): rules 1 to 4, a url key checked against the sender's Console, a tap with no route switches nothing, the MDM lock drop, the app-lock hold then switch on unlock, latest held tap wins, waiting for the saved connections to load, cold start through lock and switch routed once, cold start from a removed server opens nothing. Each routed case records the current connection at the moment the route arrives, which pins switch-before-route.
  • __tests__/lib/push-notifications.test.ts: pushSenderFromResponse (FCM, APNs, absent, null, non-string) and pushTapTarget (each rule, byte-for-byte refusals such as an upper-cased host, :443, a trailing slash, the other scheme, a bare host, the lock and the pending-managed lock).
  • __tests__/lib/connections/model.test.ts: connectionByOrigin, including the stored-document round trip (scheme kept, default port dropped, base path not part of the origin).
  • __tests__/components/ConsoleScreen.test.tsx (existing harness; its useLocalSearchParams mock became mutable): the route asked for after a switch reaches only the new connection's document, gated to its origin, after its Console says ready; and the same when the switch and the route land in one render.

Acceptance notes

  • Landing point. The claim expected app/console.tsx to own the switch-then-route sequencing. Measured: usePushRouting is mounted in app/_layout.tsx and hands routes to the Console screen as search params; the switch is the store's select, which the WebView follows in a layout effect. Switching first and routing second already delivers the route to the new document (held until ready), so the sequencing lives in the hook and app/console.tsx is untouched. app/_layout.tsx changed by two lines (the call site).
  • App lock, measured. Before this change a tap's route navigated the WebView under the overlay at once, while the switcher (the only switch path) is closed and refused while the app is locked. A push switch is a persisted change of the current connection, so it now waits for unlock like the switcher, and the route waits with it, also for rule 1, so one rule holds for every tap.
  • MDM lock, measured. The lock (lockedHost) hides other connections in the switcher but select itself does not refuse them, so the tap rule checks the lock before any switch.
  • A tap whose payload has no usable Console route switches nothing (as before, nothing opens).
  • docs/PUSH-NOTIFICATIONS.md "Prerequisites" still says the server's device registry and push channel were never built; objectstack has since shipped them. Left as is: outside this card.

Verification

All readings at head f5f9a36.

Gates
npx tsc --noEmit                      exit 0
npx eslint . --ext .ts,.tsx           exit 0
npx jest --ci --coverage              exit 0   Test Suites: 49 passed, 49 total   Tests: 1092 passed, 1092 total
npx jest --ci --no-cache __tests__/components/ConsoleScreen.test.tsx   exit 0   Tests: 20 passed (cold, 8.2 s)
coverage: usePushRouting.ts 98.18 stmts / 100 lines; push-notifications.ts 91.86 / 92.18; connections/model.ts 96.7 / 100
Ablation: the origin match

connectionByOrigin mutated to answer the first saved connection (the current one in the hook fixtures) for any string origin, through scripts/ablation-replace.mjs (anchor 1 to 0, blob changed; restore proven: blob equals HEAD, git diff HEAD empty). Hook, push and model suites: 15 failed, 70 passed. Red: connectionByOrigin (3), pushTapTarget opens-on / byte-for-byte / unsaved-with-one / locked (4), and the hook's rule 2, rule 2 url, rule 3, MDM lock, app-lock hold, waits-for-load, both cold-start cases (8).

Ablation: the app-lock hold, and a control for the switch-order pin
  • Hold removed (appLocked dropped from the hook's guard): 2 failed, 14 passed (the app-lock hold and the cold start through the lock). Restored, blob equals HEAD.
  • Control: the ConsoleScreen pin's order reversed (route asked for, then switch): that case goes red, and the received navigate script is gated to https://erp.example.com, the document being left. Restored, blob equals HEAD.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

Seat verdict: ACCEPT — Head-sha: f5f9a368650d9fba41e16fb769eccc26d956185f

维护者速读

  • 改了什么: 用户点开推送时,app 看推送里写的发送服务器,在已保存的服务器里找到同一台,在那台上打开页面;不是当前这台,就先切过去,等它的 Console 加载好再打开。
  • 为什么改: 以前不管推送是哪台服务器发的,都在当前这台上打开。保存了多台服务器的用户,点开 B 的通知会在 A 上打开同一个路径:页面错了,还可能看到 A 上同编号的另一条记录。
  • 风险与回滚: 只改了 app 端的推送路由,没碰服务器和登录。保存了多台服务器、又连着没升级的老服务端时,点开推送不再打开页面,只把 app 拉到前台,因为分不清是谁发的;只保存一台的用户不受影响。回滚就是 revert 本 PR。
  • 先看: lib/push-notifications.ts 的 pushTapTarget(决定在哪台打开),hooks/usePushRouting.ts 的 openTap(先切换,再打开)。
  • 两分钟验证: 看 docs/PUSH-NOTIFICATIONS.md 新增的「Which server a tap opens on」表,五行规则和代码一一对应。
  • 需要你做的: 审批并合并本 PR(席位落地暂停中)。另外要真机验收:保存两台服务器,都开推送,点开不是当前那台发的推送,应在它自己的服务器上打开;删掉一台后,点它以前的推送,什么都不应打开。

席位意见: 四条规则、MDM 锁定和应用锁都按派发令实现了。推送里的服务器地址只用来在已保存的服务器里挑选,app 不会去连推送点名的服务器。比对地址的方式和平台协议要求的标准格式一致,席位对照代码核过。全套 1092 个测试通过;故意改坏地址比对后有 15 个测试变红,证明测试真在把关。建议合并。

席位核对

  1. Shape: draft → main. The first line carries Fixes #174. Clause-②: no and Metadata-protocol: none are present. Patch changeset .changeset/174-push-per-connection.md. 11 files, +702 −60.
  2. Premise, measured: on main at 8e4235f, usePushRouting(serverUrl, …) resolved every tap on the current connection. The platform contract (objectstack packages/spec/src/api/push-delivery.zod.ts, PushDeliveryDataSchema.origin) is a canonical origin. The seat read the shell side: originOf (lib/shell/console-url.ts:55) builds scheme://host from parseUrl, which lower-cases the host and drops the scheme's default port. That is the form window.location.origin answers, so the byte-for-byte compare in connectionByOrigin is sound.
  3. Gates: check-runs on head f5f9a36: Lint & Type Check success, Code Quality success. mergeable_state is clean. Dev readings: tsc 0, eslint 0, jest --ci --coverage 49/49 suites and 1092 tests, cold ConsoleScreen 20/20.
  4. Ablation: an origin match that answers any saved connection turns 15 tests red. Removing the app-lock guard turns 2 red. Reversing the switch-then-route order makes the navigate script target the old document's origin, and the pin goes red.
  5. Seat code read:
    • pushTapTarget drops a tap in four cases: unsaved origin; no origin with two or more saved; nothing saved; MDM lock with another host (including a pending lock, where lockedHost is null).
    • The origin is never parsed, fetched or saved; it is only compared.
    • openTap switches with the store's select, re-checks currentHost after the await, and routes with the sender's serverUrl, so a url key is checked against the sender's Console.
    • The drop breadcrumb carries the reason only, no origin.
    • Taps wait for isReady and an open app lock. Only the latest waiting tap is kept; earlier ones are dropped by design, and the doc says so.
  6. Accepted deviations:
    • app/_layout.tsx is outside the claimed surface. The change is the call site only (−1 store read, signature change), because the hook is mounted there. It is explained in the PR.
    • app/console.tsx is not changed, because the existing switch already holds the route for the new document. The order pin proves it.
    • App lock: a tap used to navigate under the overlay at once. It now waits for unlock, like the switcher.
  7. Out of scope:
  8. Cross-PR check: ci(eas): remote build numbers; build iOS and Android in separate jobs #191 (open, awaiting the maintainer) touches eas.json, eas-build.yml, docs/DEPLOYMENT.md and its own changeset. The file sets are disjoint, so either merge order is clean.
  9. Landing: the maintainer merges, because seat landing is suspended (see [PM seat] repo:mobile — 🟢 os-justin #160). The seat marks the PR ready and requests review, and does NOT enable auto-merge.

@objectstack-fleet objectstack-fleet Bot added the needs-user-decision Blocked on a maintainer decision — do not dispatch; decision cards only, never on a PR label Nov 10, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 10, 2026 01:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-user-decision Blocked on a maintainer decision — do not dispatch; decision cards only, never on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Push taps route to the current connection, even when the notification came from another saved server

1 participant