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 +86 -20
- package/lib/auth/devices.d.ts +26 -0
- package/lib/auth/devices.js +18 -0
- package/lib/auth/store.d.ts +1 -1
- package/lib/auth/store.js +13 -0
- package/lib/bridge/index.js +44 -5
- package/lib/index.js +28 -3
- package/lib/protocol/frames.d.ts +10 -1
- package/lib/protocol/frames.js +10 -1
- package/lib/push/binding.d.ts +8 -0
- package/lib/push/binding.js +10 -2
- package/lib/push/notifier.d.ts +13 -0
- package/lib/push/notifier.js +34 -0
- package/lib/push/scope.d.ts +23 -0
- package/lib/push/scope.js +22 -0
- package/lib/push/transport.d.ts +23 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,38 +11,60 @@
|
|
|
11
11
|
|
|
12
12
|
## 一、装插件
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
**推荐用 npm 装**:插件已发布到 npm,包名 **`dsh-mizar`**,与当前 DSH 运行时(0.2.1-rc.2)对齐。
|
|
15
|
+
装完都要**重启 DSH** —— 插件只在启动时加载,不重启等于没装。
|
|
16
16
|
|
|
17
|
-
###
|
|
17
|
+
### 推荐:图形界面(不碰终端)
|
|
18
18
|
|
|
19
|
-
1. 打开 DSH
|
|
20
|
-
2. 安装输入框里填 **`dsh-mizar
|
|
21
|
-
3.
|
|
19
|
+
1. 打开 DSH,左侧栏点 **插件(Plugins)**
|
|
20
|
+
2. 安装输入框里填 **`dsh-mizar`**,点安装
|
|
21
|
+
3. 确认它弹出来的包名 / 版本 / 描述
|
|
22
|
+
4. 重启 DSH
|
|
22
23
|
|
|
23
|
-
|
|
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
|
-
|
|
31
|
+
两种装法都会把依赖写成 `dsh-mizar@^x.y.z`。升级只要再跑一次 `add dsh-mizar@latest`。
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`,见
|
|
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
|
-
|
|
224
|
+
协议、架构决策、自建中继、研究记录:[docs/](docs/)
|
package/lib/auth/devices.d.ts
CHANGED
|
@@ -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
|
}
|
package/lib/auth/devices.js
CHANGED
|
@@ -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.
|
package/lib/auth/store.d.ts
CHANGED
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
|
/**
|
package/lib/bridge/index.js
CHANGED
|
@@ -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
|
|
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
|
|
92
|
-
* @throws MethodError when
|
|
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.
|
|
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);
|
package/lib/protocol/frames.d.ts
CHANGED
|
@@ -19,7 +19,16 @@
|
|
|
19
19
|
*
|
|
20
20
|
* @module dsh-mizar/protocol/frames
|
|
21
21
|
*/
|
|
22
|
-
/**
|
|
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";
|
package/lib/protocol/frames.js
CHANGED
|
@@ -19,7 +19,16 @@
|
|
|
19
19
|
*
|
|
20
20
|
* @module dsh-mizar/protocol/frames
|
|
21
21
|
*/
|
|
22
|
-
/**
|
|
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';
|
package/lib/push/binding.d.ts
CHANGED
|
@@ -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 {
|
package/lib/push/binding.js
CHANGED
|
@@ -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
|
package/lib/push/notifier.d.ts
CHANGED
|
@@ -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.
|
package/lib/push/notifier.js
CHANGED
|
@@ -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
|
package/lib/push/scope.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/push/transport.d.ts
CHANGED
|
@@ -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 {
|