dsh-mizar 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,38 +11,60 @@
11
11
 
12
12
  ## 一、装插件
13
13
 
14
- 插件在 npm 上叫 **`dsh-mizar`**。命令行和图形界面两种装法,装完都要**重启 DSH** ——
15
- 插件只在启动时加载,不重启等于没装。
14
+ **推荐用 npm 装**:插件已发布到 npm,包名 **`dsh-mizar`**,与当前 DSH 运行时(0.2.1-rc.2)对齐。
15
+ 装完都要**重启 DSH** —— 插件只在启动时加载,不重启等于没装。
16
16
 
17
- ### 方式 A:图形界面(不想开终端就用这个)
17
+ ### 推荐:图形界面(不碰终端)
18
18
 
19
- 1. 打开 DSH,左侧栏点 **插件(Plugins)**
20
- 2. 安装输入框里填 **`dsh-mizar`**,点安装
21
- 3. 重启 DSH
19
+ 1. 打开 DSH,左侧栏点 **插件(Plugins)**
20
+ 2. 安装输入框里填 **`dsh-mizar`**,点安装
21
+ 3. 确认它弹出来的包名 / 版本 / 描述
22
+ 4. 重启 DSH
22
23
 
23
- 安装框同时接受 **npm 包名**(`dsh-mizar`)、**GitHub 地址**(`ShirazuNagisa/DSH-Mizar`)、
24
- **本地 `.tgz`** 或**目录路径**;装之前它会先把包名、版本、描述报给你确认。
25
-
26
- ### 方式 B:命令行
24
+ ### 推荐:命令行
27
25
 
28
26
  ```sh
29
- dsh plugin --profile desktop add dsh-mizar # npm 正式版
30
- dsh plugin --profile desktop add dsh-mizar@0.8.2 # 锁定版本
27
+ dsh plugin --profile desktop add dsh-mizar # 跟随 npm 最新版
28
+ dsh plugin --profile desktop add dsh-mizar@0.8.2 # 锁定某个版本
31
29
  ```
32
30
 
33
- 尝鲜(还没发版的提交)用 GitHub 直装:
31
+ 两种装法都会把依赖写成 `dsh-mizar@^x.y.z`。升级只要再跑一次 `add dsh-mizar@latest`。
34
32
 
35
- ```sh
36
- dsh plugin --profile desktop add ShirazuNagisa/DSH-Mizar
37
- ```
33
+ 重启后侧边栏会出现 **DSH-Mizar** 面板。
34
+
35
+ ### 为什么是 npm,而不是 GitHub 直装
36
+
37
+ | | **npm(推荐)** | GitHub 直装 |
38
+ |---|---|---|
39
+ | 版本 | 有版本号,可锁 `@0.8.2` | 跟随 `main` 最新提交,不可锁 |
40
+ | 可追溯 | `npm view dsh-mizar versions`、`npm outdated` | 只有 commit hash |
41
+ | 完整性 | registry **sha512 校验** | 无包完整性校验 |
42
+ | 前提 | 只要能访问 registry | 需要 git + pnpm 的 git 支持 |
43
+ | 适合 | **所有人** | 只适合尝鲜未发布的提交 |
38
44
 
39
- 离线 / 内网就传一个 `.tgz` 过去:
45
+ ### 备选:GitHub / 本地 tarball
40
46
 
41
47
  ```sh
48
+ # 尝鲜:装 main 上还没发版的提交
49
+ dsh plugin --profile desktop add ShirazuNagisa/DSH-Mizar
50
+
51
+ # 离线 / 内网:在有网的机器上 npm pack,再把 .tgz 传过去
52
+ npm pack dsh-mizar
42
53
  dsh plugin --profile desktop add /path/to/dsh-mizar-0.8.2.tgz
43
54
  ```
44
55
 
45
- 重启后侧边栏会出现 **DSH-Mizar** 面板。
56
+ ### 装不到最新版 / 镜像同步延迟
57
+
58
+ 插件发布在 npm 官方源(`registry.npmjs.org`)。国内镜像(如 `registry.npmmirror.com`)偶尔滞后,
59
+ 表现是 `pnpm` 抓到一个旧的或占位版本。两种情况:
60
+
61
+ - **加 `--registry` 直连官方源**:
62
+ ```sh
63
+ dsh plugin --profile desktop add dsh-mizar@0.8.2 --registry=https://registry.npmjs.org
64
+ ```
65
+ - 或者去 [npmmirror 同步页](https://npmmirror.com/sync/dsh-mizar) 点一次「同步」,等镜像更新。
66
+
67
+ > GUI 的安装框走的是本机默认 registry;镜像没同步时,用上面的 CLI 命令更稳。
46
68
 
47
69
  ### 版本必须与 DSH 对齐
48
70
 
@@ -97,8 +119,52 @@ Plugin dsh-mizar@0.8.2 is incompatible with dsh 0.2.1-rc.2
97
119
  **一轮结束不会立刻推。** DSH 里一个指令常常连着跑好几个 turn,每个 turn 都推一次等于一个
98
120
  指令三条通知 —— 所以插件会等这个会话安静 15 秒才发。正在看着 App 的时候什么都不发。
99
121
 
122
+ **锁屏上的 Live Activity 会跟着结束。** iPhone 把正在跑的会话画在锁屏 / 灵动岛上计时;
123
+ App 被挂起时它自己没机会收尾。所以 App 会把这次活动的推送令牌随注册交给插件,插件存下来、
124
+ 转给中继;会话跑完、且手机上还看得到的会话一个都不在跑时,那条「跑完了」的通知会让中继
125
+ 顺手结束 Live Activity —— 任务结束后计时不会一直涨。
126
+
100
127
  推送要 Apple 的签名密钥,密钥只能放在服务器上(放在插件里就等于公开)。**用官方中继
101
- 什么都不用做**;自建中继需要配 `apns`,见 [relay-deployment.md 第 10 节](docs/relay-deployment.md)。
128
+ 什么都不用做**;自建中继需要配 `apns`,见
129
+ [Relay 仓库的部署文档第 10 节](https://github.com/ShirazuNagisa/DSH-Mizar-Relay/blob/main/docs/deployment.md#10-push-notifications)。
130
+
131
+ ## 四·五、发布(维护者)
132
+
133
+ 发布走 **trusted publishing(OIDC)**:GitHub Actions 用一次性的 OIDC 身份向 npm 换发布凭证,
134
+ **没有长期令牌,也没有验证码要输**。发布就是推一个版本 tag。
135
+
136
+ 一次性配置(npmjs.com,用账号的安全密钥验证):
137
+
138
+ 1. 打开 `dsh-mizar` 包页面 → **Settings** → **Publishing access** → **Trusted publisher** →
139
+ **GitHub Actions**;
140
+ 2. 填:
141
+ - Organization or user:`ShirazuNagisa`
142
+ - Repository:`DSH-Mizar`
143
+ - Workflow filename:`publish.yml`
144
+ - Environment name:留空
145
+ - **Allowed actions**:勾上 **`npm publish`**(直接发布;只勾暂存的话每次发布还要人工批准);
146
+ 3. 保存。
147
+
148
+ 之后发布:
149
+
150
+ ```sh
151
+ cd dsh
152
+ npm version patch # 或手改 package.json 的 version
153
+ git push # 版本号先上 main
154
+ git tag vX.Y.Z && git push origin vX.Y.Z
155
+ ```
156
+
157
+ workflow(`.github/workflows/publish.yml`)会:**校验 tag 与 package.json 版本一致** →
158
+ `npm ci` → typecheck → 全部测试 → build → `npm publish --provenance`。任何一步失败都不会发布。
159
+
160
+ 为什么不用手工 `npm publish`:这个账号的第二因子是**安全密钥(WebAuthn)**,npm CLI 不支持
161
+ WebAuthn,`Enter OTP:` 永远过不去;trusted publishing 是 npm 对 CI 发布的官方方案,还顺带生成
162
+ 来源证明(provenance)。
163
+
164
+ > 兜底:`npm publish --registry=https://registry.npmjs.org` 仍可用,但需要账号里有可用的
165
+ > 验证器 App(TOTP)或一次性密码;若走 `auth-type=web` 会进入暂存发布,registry 上出现
166
+ > `0.0.0-stage` 占位版本时用
167
+ > `npm unpublish dsh-mizar@0.0.0-stage --registry=https://registry.npmjs.org` 清掉。
102
168
 
103
169
  ## 五、开发
104
170
 
@@ -155,4 +221,4 @@ DSH 启动日志里有 `[mizar] dsh-mizar <版本号> · state: … · devices:
155
221
 
156
222
  ---
157
223
 
158
- 协议、架构决策、自建中继:[docs/](docs/)
224
+ 协议、架构决策、自建中继、研究记录:[docs/](docs/)
@@ -2,6 +2,20 @@
2
2
  export declare const TOKEN_PREFIX = "mz_";
3
3
  /** Longest accepted device name, in characters. */
4
4
  export declare const MAX_DEVICE_NAME = 64;
5
+ /** Longest accepted Live Activity push token, in characters. */
6
+ export declare const MAX_LIVE_ACTIVITY_TOKEN = 200;
7
+ /**
8
+ * Whether a value is an APNs Live Activity push token.
9
+ *
10
+ * Lowercase hex, because that is what APNs issues and therefore what the app
11
+ * reports. The length bound is the wire contract's: wide enough for any token
12
+ * APNs has ever issued, narrow enough that a stored file cannot be made to hold
13
+ * an unbounded string.
14
+ *
15
+ * @param value - the candidate, normally straight from a client payload or from state.json.
16
+ * @returns whether it is usable.
17
+ */
18
+ export declare function isLiveActivityToken(value: unknown): value is string;
5
19
  /** One paired device. */
6
20
  export interface DeviceRecord {
7
21
  /** Stable identity, generated at pairing time. */
@@ -59,6 +73,18 @@ export interface DevicePush {
59
73
  readonly environment: 'sandbox' | 'production' | null;
60
74
  /** The language the app is running in, so notifications match it. */
61
75
  readonly locale: string;
76
+ /**
77
+ * The APNs push token of the Live Activity currently on screen.
78
+ *
79
+ * Not the same token as {@link DevicePush.token}, and not interchangeable: a
80
+ * Live Activity is addressed by a per-activity token, so an end push sent to
81
+ * the app's device token would reach the app rather than the activity.
82
+ *
83
+ * Absent means no Live Activity is up. That is why registering without it has
84
+ * to *clear* the stored one rather than leave it alone: a token held past its
85
+ * activity is a credential for ending a countdown that no longer exists.
86
+ */
87
+ readonly liveActivityToken?: string;
62
88
  /** When the app last told this host where it is. */
63
89
  readonly updatedAt: number;
64
90
  }
@@ -17,6 +17,24 @@ export const TOKEN_PREFIX = 'mz_';
17
17
  const TOKEN_BYTES = 32;
18
18
  /** Longest accepted device name, in characters. */
19
19
  export const MAX_DEVICE_NAME = 64;
20
+ /** Longest accepted Live Activity push token, in characters. */
21
+ export const MAX_LIVE_ACTIVITY_TOKEN = 200;
22
+ /** APNs issues lowercase hex tokens and nothing else. */
23
+ const LIVE_ACTIVITY_TOKEN_PATTERN = new RegExp(`^[0-9a-f]{1,${MAX_LIVE_ACTIVITY_TOKEN}}$`);
24
+ /**
25
+ * Whether a value is an APNs Live Activity push token.
26
+ *
27
+ * Lowercase hex, because that is what APNs issues and therefore what the app
28
+ * reports. The length bound is the wire contract's: wide enough for any token
29
+ * APNs has ever issued, narrow enough that a stored file cannot be made to hold
30
+ * an unbounded string.
31
+ *
32
+ * @param value - the candidate, normally straight from a client payload or from state.json.
33
+ * @returns whether it is usable.
34
+ */
35
+ export function isLiveActivityToken(value) {
36
+ return typeof value === 'string' && LIVE_ACTIVITY_TOKEN_PATTERN.test(value);
37
+ }
20
38
  /**
21
39
  * Hash a token for storage or comparison.
22
40
  * @param token - the token.
@@ -1,4 +1,4 @@
1
- import type { DevicePush, DeviceRecord } from './devices.js';
1
+ import { type DevicePush, type DeviceRecord } from './devices.js';
2
2
  import type { PairingSlot } from './pairing.js';
3
3
  /** Schema version of the state file. */
4
4
  export declare const STATE_VERSION = 1;
package/lib/auth/store.js CHANGED
@@ -19,6 +19,7 @@ import { randomBytes, randomUUID } from 'node:crypto';
19
19
  import { mkdir, open, readFile, rename, unlink, appendFile } from 'node:fs/promises';
20
20
  import { homedir } from 'node:os';
21
21
  import { dirname, join } from 'node:path';
22
+ import { isLiveActivityToken } from './devices.js';
22
23
  /** Schema version of the state file. */
23
24
  export const STATE_VERSION = 1;
24
25
  /** Bytes in the host secret. */
@@ -68,6 +69,11 @@ function createState(now = Date.now()) {
68
69
  * device re-registers on its next launch, and this file is the wrong place for a
69
70
  * recoverable field to become a lockout.
70
71
  *
72
+ * The Live Activity token inside it gets the same treatment one level down: an
73
+ * unusable one costs the activity, not the endpoint, because a phone that can
74
+ * still be notified is worth more than one whose corrupt extra field made the
75
+ * whole registration vanish.
76
+ *
71
77
  * @param value - the candidate.
72
78
  * @returns the endpoint, or `null` when it is unusable.
73
79
  */
@@ -95,12 +101,19 @@ function parsePush(value) {
95
101
  platform === 'ios'
96
102
  ? 'production'
97
103
  : null;
104
+ // Validated on the way in and on the way back out, because this is the copy
105
+ // the relay is told again after a restart -- a token that rotted in the file
106
+ // would be re-announced as if it were still a Live Activity.
107
+ const liveActivityToken = isLiveActivityToken(record['liveActivityToken'])
108
+ ? record['liveActivityToken']
109
+ : undefined;
98
110
  return {
99
111
  token: record['token'],
100
112
  platform,
101
113
  environment,
102
114
  locale: record['locale'],
103
115
  updatedAt: record['updatedAt'],
116
+ ...(liveActivityToken === undefined ? {} : { liveActivityToken }),
104
117
  };
105
118
  }
106
119
  /**
@@ -1,3 +1,4 @@
1
+ import { isLiveActivityToken } from '../auth/devices.js';
1
2
  import { deliverQuestionAnswers } from './answerers.js';
2
3
  import { toMethodError } from './errors.js';
3
4
  import { CLIENT_APPROVAL_OUTCOMES, InteractionBroker, } from './interactions.js';
@@ -71,7 +72,35 @@ function readAnswerBatch(payload) {
71
72
  });
72
73
  }
73
74
  /**
74
- * Read a `push.register`'s platform and environment.
75
+ * Read the optional Live Activity token a registration carries.
76
+ *
77
+ * Absence is meaningful rather than "no change": the app only sends this while
78
+ * a Live Activity is up, so a registration without it says none is up now and
79
+ * the stored token must be cleared. That is why the answer is `string | null`
80
+ * rather than `string | undefined` -- the binding has to act on the absence.
81
+ *
82
+ * A present value is held, not just forwarded, because it has to outlive this
83
+ * launch: the relay is told again after a reconnect, and the app may be
84
+ * suspended long before the turn that ends the activity.
85
+ *
86
+ * @param record - the registration payload.
87
+ * @returns the token, or `null` when the app has no Live Activity up.
88
+ * @throws MethodError when a value is present but is not a usable token.
89
+ */
90
+ function readLiveActivityToken(record) {
91
+ const value = record['liveActivityToken'];
92
+ // Absent, not null and not empty: those are present values that failed the
93
+ // contract, and a client sending one has a bug worth naming rather than
94
+ // silently having its activity token cleared.
95
+ if (value === undefined)
96
+ return null;
97
+ if (!isLiveActivityToken(value)) {
98
+ throw new MethodError('MALFORMED_FRAME', 'push.register: payload."liveActivityToken" must be 1-200 lowercase hex characters');
99
+ }
100
+ return value;
101
+ }
102
+ /**
103
+ * Read a `push.register`'s platform, environment and Live Activity token.
75
104
  *
76
105
  * `platform` is optional and means iOS when absent, which is the one thing that
77
106
  * makes this change compatible: every app build that exists today sends
@@ -87,9 +116,13 @@ function readAnswerBatch(payload) {
87
116
  * is dropped rather than rejected: FCM has one service and the field is
88
117
  * meaningless there.
89
118
  *
119
+ * `liveActivityToken` is likewise iOS-only and optional. An Android client has
120
+ * no Live Activity to end, so a stray value is dropped rather than refused, for
121
+ * the same reason a stray environment is.
122
+ *
90
123
  * @param payload - the request payload.
91
- * @returns the platform, and the environment when there is one.
92
- * @throws MethodError when either is present but not one of the allowed values.
124
+ * @returns the platform, the environment and the activity token when there are any.
125
+ * @throws MethodError when a present field is not one of the allowed values.
93
126
  */
94
127
  function readRegistration(payload) {
95
128
  const record = optionalObject(payload, 'push.register');
@@ -103,12 +136,12 @@ function readRegistration(payload) {
103
136
  // nothing for an Android client to answer here -- and a client that sends
104
137
  // one anyway must still register, because refusing would make a version of
105
138
  // the app unable to pair for a reason that cannot be true.
106
- return { platform, environment: null };
139
+ return { platform, environment: null, liveActivityToken: null };
107
140
  }
108
141
  if (value !== 'sandbox' && value !== 'production') {
109
142
  throw new MethodError('MALFORMED_FRAME', 'push.register: payload."environment" must be "sandbox" or "production" for platform "ios"');
110
143
  }
111
- return { platform, environment: value };
144
+ return { platform, environment: value, liveActivityToken: readLiveActivityToken(record) };
112
145
  }
113
146
  /**
114
147
  * Build the method table for one plugin mount.
@@ -255,6 +288,11 @@ export function createMethodTable(ctx, facts, broker, push) {
255
288
  * notification permission -- so a phone that refused simply never calls it,
256
289
  * and nothing needs to model refusal.
257
290
  *
291
+ * The optional `liveActivityToken` is the one field that is *cleared* by
292
+ * absence. The app sends it while a Live Activity is on screen and omits it
293
+ * afterwards, so a registration without it is the signal that the countdown
294
+ * is gone and the stored token must go with it.
295
+ *
258
296
  * Answers `registered: false` rather than failing when the device is not on
259
297
  * the roster. That cannot happen through the listener (every method is behind
260
298
  * authentication), and a client that met it would have nothing useful to do
@@ -274,6 +312,7 @@ export function createMethodTable(ctx, facts, broker, push) {
274
312
  platform: registration.platform,
275
313
  environment: registration.environment,
276
314
  locale: optionalString(payload, 'locale') ?? 'en',
315
+ liveActivityToken: registration.liveActivityToken,
277
316
  }),
278
317
  };
279
318
  },
package/lib/index.js CHANGED
@@ -14,11 +14,11 @@ import { Config } from './config.js';
14
14
  import { registerAnswerers } from './bridge/answerers.js';
15
15
  import { createMethodTable } from './bridge/index.js';
16
16
  import { InteractionBroker } from './bridge/interactions.js';
17
- import { sessionStats } from './bridge/sessions.js';
17
+ import { listSessions, sessionStats } from './bridge/sessions.js';
18
18
  import { registerPanelApi } from './panel/api.js';
19
19
  import { createPushBinding } from './push/binding.js';
20
20
  import { PushNotifier } from './push/notifier.js';
21
- import { isNotifiable } from './push/scope.js';
21
+ import { anyNotifiableSessionRunning, isNotifiable, } from './push/scope.js';
22
22
  import { NoopTransport, RelayTransport } from './push/transport.js';
23
23
  import { MizarStore, resolveMizarHome } from './auth/store.js';
24
24
  import { selectBindAddresses, snapshotInterfaces, } from './net/interfaces.js';
@@ -53,7 +53,7 @@ const systemLocale = (() => {
53
53
  * code in place and every symptom unchanged. One line at boot turns that from a
54
54
  * guess into a fact.
55
55
  */
56
- const MIZAR_VERSION = '0.8.2';
56
+ const MIZAR_VERSION = '0.8.3';
57
57
  /**
58
58
  * Services this plugin must observe before it activates.
59
59
  *
@@ -170,8 +170,33 @@ async function startListener(ctx, config, signal) {
170
170
  const pushTransport = relayBaseURL === null
171
171
  ? new NoopTransport()
172
172
  : new RelayTransport({ baseURL: relayBaseURL, token: tunnelToken, log: report });
173
+ // The question a finished turn has to answer before it may take the Live
174
+ // Activity down: is anything the phone would show still working? Read through
175
+ // the same list the app draws its rows from, and filtered by the same rule as
176
+ // the notification scope, so "idle" means idle on the phone rather than idle
177
+ // in some other sense.
178
+ //
179
+ // A gateway that cannot be read is not evidence that everything stopped: the
180
+ // failure direction is "still running", because a countdown that lingers is a
181
+ // wrong clock, while one that vanishes under a running session is a wrong
182
+ // screen. Reported once -- this runs per notification, and a down gateway
183
+ // would otherwise write a line every time one finished.
184
+ let warnedAboutSessionList = false;
185
+ const anySessionRunning = async () => {
186
+ try {
187
+ return anyNotifiableSessionRunning((await listSessions(ctx)).items);
188
+ }
189
+ catch (error) {
190
+ if (!warnedAboutSessionList) {
191
+ warnedAboutSessionList = true;
192
+ report(`push: could not read the session list — leaving the live activity up (${describe(error)})`);
193
+ }
194
+ return true;
195
+ }
196
+ };
173
197
  const notifier = new PushNotifier({
174
198
  transport: pushTransport,
199
+ anySessionRunning,
175
200
  describeSession: async (sessionId) => {
176
201
  try {
177
202
  const frame = await sessionStats(ctx, sessionId);
@@ -19,7 +19,16 @@
19
19
  *
20
20
  * @module dsh-mizar/protocol/frames
21
21
  */
22
- /** The protocol version this build speaks. */
22
+ /**
23
+ * The protocol version this build speaks.
24
+ *
25
+ * **Adding an optional field to an existing payload is not a version bump**, and
26
+ * the Live Activity fields (the bridge's `push.register.liveActivityToken`, and
27
+ * the relay's `endLiveActivity`) are added that way: a peer that does not know a
28
+ * key leaves it alone, which is what lets a phone update before its host or the
29
+ * other way round. Changing what an existing field means, renaming one, or
30
+ * requiring a new one *is* a bump.
31
+ */
23
32
  export declare const PROTOCOL_VERSION = 1;
24
33
  /** Path of the authenticated WebSocket endpoint. */
25
34
  export declare const WS_PATH = "/mizar/v1/ws";
@@ -19,7 +19,16 @@
19
19
  *
20
20
  * @module dsh-mizar/protocol/frames
21
21
  */
22
- /** The protocol version this build speaks. */
22
+ /**
23
+ * The protocol version this build speaks.
24
+ *
25
+ * **Adding an optional field to an existing payload is not a version bump**, and
26
+ * the Live Activity fields (the bridge's `push.register.liveActivityToken`, and
27
+ * the relay's `endLiveActivity`) are added that way: a peer that does not know a
28
+ * key leaves it alone, which is what lets a phone update before its host or the
29
+ * other way round. Changing what an existing field means, renaming one, or
30
+ * requiring a new one *is* a bump.
31
+ */
23
32
  export const PROTOCOL_VERSION = 1;
24
33
  /** Path of the authenticated WebSocket endpoint. */
25
34
  export const WS_PATH = '/mizar/v1/ws';
@@ -30,6 +30,14 @@ export interface PushRegistrationInput {
30
30
  */
31
31
  readonly environment: 'sandbox' | 'production' | null;
32
32
  readonly locale: string;
33
+ /**
34
+ * The APNs push token of the Live Activity on screen, or `null` when none is.
35
+ *
36
+ * `null` clears the stored token rather than leaving it alone: the app omits
37
+ * the field once its activity is gone, and a token kept past that point is a
38
+ * credential for ending a countdown that no longer exists.
39
+ */
40
+ readonly liveActivityToken: string | null;
33
41
  }
34
42
  /** What the bridge calls. */
35
43
  export interface PushBinding {
@@ -16,6 +16,7 @@ export function createPushBinding(options) {
16
16
  platform: push.platform,
17
17
  environment: push.environment,
18
18
  locale: push.locale,
19
+ ...(push.liveActivityToken === undefined ? {} : { liveActivityToken: push.liveActivityToken }),
19
20
  };
20
21
  };
21
22
  return {
@@ -26,6 +27,9 @@ export function createPushBinding(options) {
26
27
  environment: input.environment,
27
28
  locale: input.locale,
28
29
  updatedAt: Date.now(),
30
+ // Omitted when there is none, which is how `setDevicePush` is told to
31
+ // clear it: this object is the whole record, not a patch.
32
+ ...(input.liveActivityToken === null ? {} : { liveActivityToken: input.liveActivityToken }),
29
33
  });
30
34
  if (!known) {
31
35
  // A device that is not on the roster cannot have got here: every method
@@ -40,12 +44,16 @@ export function createPushBinding(options) {
40
44
  platform: input.platform,
41
45
  environment: input.environment,
42
46
  locale: input.locale,
47
+ ...(input.liveActivityToken === null ? {} : { liveActivityToken: input.liveActivityToken }),
43
48
  });
49
+ // Whether a Live Activity token is held, never the token itself: it is a
50
+ // notification credential, and this log is what support requests quote.
51
+ const activity = input.liveActivityToken === null ? 'live-activity token: none' : 'live-activity token: held';
44
52
  // Only the first eight characters of the token, which is enough to tell
45
53
  // two registrations apart in a log and not enough to be one.
46
54
  options.log(accepted
47
- ? `push: ${deviceId} registered (${input.platform}, ${input.environment ?? 'no environment'}, ${input.locale}) — ${input.token.slice(0, 8)}…`
48
- : `push: the relay did not take ${deviceId}'s token (${input.platform}, ${input.environment ?? 'no environment'}) — this phone will not be woken`);
55
+ ? `push: ${deviceId} registered (${input.platform}, ${input.environment ?? 'no environment'}, ${input.locale}) — ${input.token.slice(0, 8)}…, ${activity}`
56
+ : `push: the relay did not take ${deviceId}'s token (${input.platform}, ${input.environment ?? 'no environment'}) — this phone will not be woken, ${activity}`);
49
57
  // The roster keeps the token either way: the relay may be an older build
50
58
  // today and accept it after an upgrade, and `republish` hands it over
51
59
  // again on the next reconnect. What must not happen is telling the phone
@@ -62,6 +62,19 @@ export interface PushNotifierOptions {
62
62
  readonly log: (message: string) => void;
63
63
  /** How long a session must be quiet, in milliseconds. */
64
64
  readonly settleMs?: number;
65
+ /**
66
+ * Whether any session the phone would show is still working.
67
+ *
68
+ * Asked only when a `turn-complete` notification is about to go out, to decide
69
+ * whether the relay should end the Live Activity with it. A Live Activity is
70
+ * the countdown for the work on screen, so while another visible session is
71
+ * still running ending it would take away the wrong screen.
72
+ *
73
+ * Absent when the host cannot answer, and then nothing is ever ended: a
74
+ * countdown that lingers is wrong, but one that disappears under a running
75
+ * session is wrong and loses the screen the operator was using.
76
+ */
77
+ readonly anySessionRunning?: () => Promise<boolean>;
65
78
  }
66
79
  /**
67
80
  * Which language to write a notification in.
@@ -78,6 +78,8 @@ export class PushNotifier {
78
78
  #foreground = new Set();
79
79
  /** Sessions that have stopped and are waiting out the quiet period. */
80
80
  #settling = new Map();
81
+ /** Whether the one "could not tell whether anything is running" line was logged. */
82
+ #warnedLiveActivity = false;
81
83
  constructor(options) {
82
84
  this.#options = options;
83
85
  this.#settleMs = Math.max(0, options.settleMs ?? DEFAULT_SETTLE_MS);
@@ -223,6 +225,11 @@ export class PushNotifier {
223
225
  async #announceSettled(sessionId, reason) {
224
226
  const session = await this.#describe(sessionId);
225
227
  const wording = turnWording(reason, session, languageOf(this.#options.locale()));
228
+ // Only a turn that finished can be the end of the work, and only when
229
+ // nothing the phone would show is still going. The app ends its own Live
230
+ // Activity while it is running; this is that same end for the case the app
231
+ // cannot cover -- one that arrives while the phone is suspended.
232
+ const endLiveActivity = await this.#nothingIsRunning();
226
233
  await this.#send({
227
234
  kind: 'turn-complete',
228
235
  title: wording.title,
@@ -233,8 +240,35 @@ export class PushNotifier {
233
240
  // the first rather than stacking under it.
234
241
  collapseId: `turn-${sessionId}`,
235
242
  timeSensitive: false,
243
+ ...(endLiveActivity ? { endLiveActivity: true } : {}),
236
244
  });
237
245
  }
246
+ /**
247
+ * Whether it is safe to ask the relay to end the Live Activity.
248
+ *
249
+ * Defaults to `false` everywhere it cannot tell: no callback, or a callback
250
+ * that failed. Ending the countdown on a guess would remove the screen a
251
+ * still-running session owns, which is the one failure the feature must not
252
+ * have -- leaving it up is only a stale clock.
253
+ */
254
+ async #nothingIsRunning() {
255
+ const check = this.#options.anySessionRunning;
256
+ if (check === undefined)
257
+ return false;
258
+ try {
259
+ return (await check()) === false;
260
+ }
261
+ catch (error) {
262
+ // Once, not once per announcement: a gateway that is down would otherwise
263
+ // write a line for every session that finishes for as long as it is down.
264
+ if (!this.#warnedLiveActivity) {
265
+ this.#warnedLiveActivity = true;
266
+ const detail = error instanceof Error ? error.message : String(error);
267
+ this.#options.log(`push: could not tell whether any session is running — leaving the live activity up (${detail})`);
268
+ }
269
+ return false;
270
+ }
271
+ }
238
272
  /** Name a session, tolerating a host that will not say. */
239
273
  async #describe(sessionId) {
240
274
  // Through `languageOf`, not a `startsWith('zh')` of its own: a Traditional
@@ -43,3 +43,26 @@ export interface ScopedSessionHeader {
43
43
  * @returns whether the phone would show a row for it.
44
44
  */
45
45
  export declare function isNotifiable(header: ScopedSessionHeader | undefined): boolean;
46
+ /** As much of a session-list row as the idle check reads. */
47
+ export interface RunningSessionRow {
48
+ /** Whether a turn is currently executing. */
49
+ readonly running: boolean;
50
+ /** `'subagent'` for a delegated child. */
51
+ readonly origin: string | null;
52
+ /** Set on a fork, and on a delegated child's own header. */
53
+ readonly parentSessionId: string | null;
54
+ }
55
+ /**
56
+ * Whether any session the phone draws a row for is still working.
57
+ *
58
+ * The one question that decides whether a finished turn may take the Live
59
+ * Activity down with it: the island belongs to the work on screen, so a hidden
60
+ * subagent or fork still running is not a reason to keep it up, and a visible
61
+ * session still running is. Written over rows rather than headers because that
62
+ * is the shape `listSessions` returns, and mapped through {@link isNotifiable}
63
+ * so the two questions cannot disagree about which sessions are visible.
64
+ *
65
+ * @param rows - the session list, as `listSessions` returned it.
66
+ * @returns whether the Mac is still working on something the phone shows.
67
+ */
68
+ export declare function anyNotifiableSessionRunning(rows: readonly RunningSessionRow[]): boolean;
package/lib/push/scope.js CHANGED
@@ -42,3 +42,25 @@ export function isNotifiable(header) {
42
42
  return false;
43
43
  return header.parentSession === undefined;
44
44
  }
45
+ /**
46
+ * Whether any session the phone draws a row for is still working.
47
+ *
48
+ * The one question that decides whether a finished turn may take the Live
49
+ * Activity down with it: the island belongs to the work on screen, so a hidden
50
+ * subagent or fork still running is not a reason to keep it up, and a visible
51
+ * session still running is. Written over rows rather than headers because that
52
+ * is the shape `listSessions` returns, and mapped through {@link isNotifiable}
53
+ * so the two questions cannot disagree about which sessions are visible.
54
+ *
55
+ * @param rows - the session list, as `listSessions` returned it.
56
+ * @returns whether the Mac is still working on something the phone shows.
57
+ */
58
+ export function anyNotifiableSessionRunning(rows) {
59
+ return rows.some((row) => row.running &&
60
+ isNotifiable({
61
+ // `null` is "this field is not there", which is what the header type
62
+ // uses absence for.
63
+ ...(row.origin === null ? {} : { origin: row.origin }),
64
+ ...(row.parentSessionId === null ? {} : { parentSession: row.parentSessionId }),
65
+ }));
66
+ }
@@ -47,6 +47,16 @@ export interface PushRegistration {
47
47
  * row that says which language it asked for is one less thing to guess.
48
48
  */
49
49
  readonly locale: string;
50
+ /**
51
+ * The APNs token of the Live Activity currently on screen, when there is one.
52
+ *
53
+ * Omitted when no activity is up. The relay keeps it so it can end the
54
+ * activity from the Mac side, which is the case the app cannot cover: a turn
55
+ * that finishes while the app is suspended has nobody on the phone to end the
56
+ * countdown. An older relay ignores the field, exactly as it ignores any other
57
+ * JSON key it does not know.
58
+ */
59
+ readonly liveActivityToken?: string;
50
60
  }
51
61
  /** One notification. */
52
62
  export interface PushMessage {
@@ -78,6 +88,19 @@ export interface PushMessage {
78
88
  * has to be there when the app goes away again.
79
89
  */
80
90
  readonly exceptDeviceIds: readonly string[];
91
+ /**
92
+ * Whether the relay should end the Live Activity alongside this notification.
93
+ *
94
+ * Only ever set on a `turn-complete` message, and only when the session that
95
+ * finished was the last one the phone would show still running: an end push
96
+ * tears the countdown down, so sending it while other work is still going
97
+ * would take away the screen the operator is waiting on.
98
+ *
99
+ * Omitted when false, rather than sent as `false`: an older relay then reads
100
+ * the body it always did. Only the relay can act on it, so this is a request,
101
+ * not a promise -- the phones that are asleep are exactly why it exists.
102
+ */
103
+ readonly endLiveActivity?: boolean;
81
104
  }
82
105
  /** Where notifications go. */
83
106
  export interface PushTransport {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mizar",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Remote monitoring and guidance for DSH agents — host plugin backing the DSH-Mizar iOS app.",
5
5
  "license": "MIT",
6
6
  "type": "module",