@jdscript/opencode-notify 0.0.0-stage → 0.1.1

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
@@ -1,3 +1,210 @@
1
- # Temporary Holding Version
1
+ # OpenCode encrypted mobile notifications
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An independently installable **OpenCode v2 plugin**. It observes semantic events
4
+ inside OpenCode and sends end-to-end encrypted alerts and Live Activity updates to
5
+ the [Rust relay](../notification-service/README.md). It uses standard plugin RPC,
6
+ storage and event APIs; no OpenCode fork, external connector process or Apple key
7
+ is required on the OpenCode instance.
8
+
9
+ Validated with the **published, unmodified `@opencode/sdk` 2.0.26**, using normal
10
+ `plugins` configuration and Bun 1.4.0. Earlier v2 versions have not been validated;
11
+ the app negotiates `@jdscript/opencode-notify/mobile-push` application protocol v1 before handing over keys.
12
+
13
+ ## Install from this checkout
14
+
15
+ ```sh
16
+ cd notification-plugin
17
+ bun install --frozen-lockfile
18
+ bun run build
19
+ ```
20
+
21
+ Add the built plugin directory to the OpenCode server user's **global**
22
+ `~/.config/opencode/opencode.json` (or the corresponding XDG config directory):
23
+
24
+ ```json
25
+ {
26
+ "plugins": ["/absolute/path/to/OpenCode-iOS/notification-plugin"]
27
+ }
28
+ ```
29
+
30
+ Global configuration makes the sender available regardless of which project the
31
+ mobile app opens. Directory-local configuration limits when the plugin loads and
32
+ is not the intended server-wide enrollment setup. The root `index.js` supports
33
+ OpenCode's local-directory entry discovery; build `dist/` before loading it.
34
+
35
+ The package is prepared as **`@jdscript/opencode-notify`**, but this checkout does
36
+ not publish it to npm. Once published, the equivalent global configuration is:
37
+
38
+ ```json
39
+ { "plugins": ["@jdscript/opencode-notify"] }
40
+ ```
41
+
42
+ The same package can be bundled into a fork as a default plugin. That is optional;
43
+ the implementation and RPC protocol are identical for upstream installations.
44
+
45
+ ## Pair and subscribe
46
+
47
+ 1. Deploy the relay with Apple credentials for the iOS app's signed bundle/environment.
48
+ 2. In the app's Settings → Notifications → Encrypted push, pair the relay using an
49
+ operator-issued one-use enrollment ticket. The app registers its APNs token.
50
+ 3. Enable a saved OpenCode server. The app negotiates the plugin, creates a distinct
51
+ relay subscription, and transfers its **send-only capability**, recipient public
52
+ key and authentication secret over its existing authenticated OpenCode connection.
53
+ 4. Ongoing work automatically gets one cross-server Live Activity. The app registers
54
+ independently authorized source routes for its single ActivityKit token, then
55
+ attaches each source with a bounded initial session/shell inventory.
56
+
57
+ The iOS default gateway URL is **`https://opencode-mps-prod.jdscript.app`**. Users
58
+ can enter their own HTTPS gateway and reset the selection with **Use default**.
59
+ The app persists this preference, while an existing pairing stays bound to its
60
+ original gateway. Changing a paired gateway requires unpairing and enrolling at the
61
+ new one. `subscribe.relayURL` is taken from the **paired device credential's origin**,
62
+ not an unsaved text-field value. The plugin sends to that origin's `/v1/push`;
63
+ it has no hardcoded default gateway and receives no relay management credential.
64
+
65
+ An editable URL does not grant APNs authority. A direct self-hosted sender needs
66
+ Apple credentials authorized for the installed app's bundle/team. Developers signing
67
+ their own app can use their own keys. Stock-app users cannot directly replace the
68
+ publisher's APNs sender merely by deploying this relay. A forwarding adapter to a
69
+ publisher-owned delivery service would be a separate integration, not present here.
70
+
71
+ OpenCode needs no global identity/account at the relay. Its durable plugin storage
72
+ contains subscription configuration and keys authorized by that device. Relay/device
73
+ management tokens and Apple device/activity tokens never enter this plugin.
74
+ HTTPS is required for remote relay origins; loopback HTTP is supported for local
75
+ development. The app also requires confidential transport for its key handoff to OpenCode.
76
+
77
+ ## RPC contract
78
+
79
+ The exported `./rpc` definition is a portable Standard Schema definition. Generic
80
+ The RPC ID is `@jdscript/opencode-notify/mobile-push`. HTTP calls use
81
+ `POST /api/rpc/{rpcID}/{method}`, `{ "input": ... }`, and the normal OpenCode
82
+ authentication/location context. The entire RPC ID is **one percent-encoded path
83
+ segment**, for example:
84
+
85
+ ```text
86
+ POST /api/rpc/%40jdscript%2Fopencode-notify%2Fmobile-push/info
87
+ ```
88
+
89
+ Both the OpenCode SDK and iOS client encode the parameter. Reverse proxies must
90
+ preserve encoded slashes when forwarding it. Responses use `{ "output": ... }`.
91
+
92
+ | Method | Input | Output |
93
+ | --- | --- | --- |
94
+ | `info` | `{}` | `{version:1, encoding:"aes128gcm", applicationVersion:1, activityScopes:["session","server","workspace"]}` |
95
+ | `subscribe` | `subscriptionID`, positive `revision`, `relayURL`, `sendToken`, `expiresAt`, `serverID`, `installationID`, `recipientPublicKey`, `authSecret`, `preferences` | Safe subscription status |
96
+ | `status` | `{subscriptionID}` | `{subscriptionID, revision, expiresAt, state}` |
97
+ | `unsubscribe` | `{subscriptionID}` | `{ok:true}` |
98
+ | `attach` | `{subscriptionID, activityID, sessionID, expiresAt, scope?, serverName?, seedComplete?, seeds?, shells?}` | `{ok:true}` |
99
+ | `detach` | `{subscriptionID, activityID}` | `{ok:true}` |
100
+
101
+ IDs are lowercase UUIDs except OpenCode's session ID. Keys use unpadded base64url:
102
+ 65-byte X9.63 P-256 public key and independent 16-byte RFC 8291 authentication
103
+ secret. `preferences` is `{completed:boolean, requests:boolean, failures:boolean}`.
104
+ Dates are Unix seconds; subscription expiry is at most 90 days, activity expiry at
105
+ most eight hours. Receiver keys/binding cannot change under an existing subscription
106
+ ID; generate a fresh subscription for a new binding. Sender credential/preferences
107
+ changes use increasing revisions; identical current-revision retries are idempotent.
108
+
109
+ Server overviews use `scope: "server"` and the binding sentinel `sessionID: "ses_workspace"`.
110
+ Optional `seeds` contain at most 512 `{sessionID, running, waiting: requestID[]}`
111
+ entries; `shells` contains at most 512 `{id, sessionID?}` entries. These seed current
112
+ observations; later server events take precedence. Session routes remain supported
113
+ for existing clients, and a route cannot change scope after attachment. Summaries
114
+ include at most three attention-first root-session rows and an optional total count.
115
+ An incomplete seed or restarted observer omits the exact count and does not claim completion.
116
+
117
+ Unified activities use `scope: "workspace"`. Their push requests carry `event:
118
+ "update"` and `settled: boolean`; a source must never end the shared system activity
119
+ on its own. The gateway groups source routes using device-owner registration,
120
+ serializes their APNs timestamps, and emits the system end only after all expected
121
+ sources settle. The widget caches separately authenticated encrypted fragments, so
122
+ one source does not replace another's view. This requires the gateway's advertised
123
+ `workspace_activities` capability and migration `0003_workspace_activities.sql`.
124
+
125
+ Expected RPC failures have type `invalid` and a safe `data.code`, such as
126
+ `subscription_not_found`, `subscription_revoked`, `stale_revision`, `revision_conflict`,
127
+ `invalid_relay_origin`, `activity_ended` or `content_too_large`. Status never returns
128
+ keys, sender tokens, relay credentials or notification content.
129
+
130
+ ## Event and delivery behavior
131
+
132
+ - Observes native execution/status, permission/form and shell events. No token/text
133
+ deltas, model calls, transcript copies, tool arguments or output are sent.
134
+ - Primary completion/failure alerts wait for observed descendant/background work to
135
+ settle; permission/form requests notify promptly. Counts describe observed activity.
136
+ - After a new load, a session without an observed execution/status is "Awaiting
137
+ activity", not inferred complete from a previous run's historical outcome. Existing
138
+ work that predates plugin activation needs subsequent events to establish live state.
139
+ - A host's Location plugin copies share a runtime, preventing duplicate notifications
140
+ when multiple projects are open. Separate embedded hosts have independent state.
141
+ - Progress changes coalesce to at most one update per five seconds; attention/end
142
+ wait only for a strictly newer timestamp if occurring in the same second. A tracked
143
+ activity gets a 30-second freshness update. Rendering a timer needs no per-second push.
144
+ - Outbox records persist already-encrypted bodies and event UUIDs through server
145
+ restart. Retries reuse exact ciphertext, respect `Retry-After` and stop at content
146
+ expiry/12 attempts. Live updates replace superseded pending updates. No exactly-once
147
+ delivery guarantee is implied; the relay and receiver also deduplicate.
148
+ - Revocation removes pending work. Already-dispatched requests can complete; central
149
+ relay revocation is the authority for rejecting later sends. Invalid credentials
150
+ pause sending until device setup is repaired. Ended route IDs cannot be reused.
151
+ - Relay POSTs follow no redirects, have a 15-second deadline and contain only the
152
+ send capability, ciphertext and necessary activity lifecycle controls.
153
+ - Billing rejection (HTTP 402) drops denied work and briefly suppresses further
154
+ paid updates without treating the sender credential as invalid. Authorized
155
+ activity ends remain possible; later activity retries can observe renewed access.
156
+
157
+ The plugin caps subscriptions at 128 and pending outbox entries at 512 per host.
158
+ Completed alert source IDs retain deduplication receipts for one day. Device subscription
159
+ management remains in the app/relay; this plugin does not create accounts or host an
160
+ additional network listener.
161
+
162
+ ## Encryption and application payload
163
+
164
+ RFC 8291 `aes128gcm` is implemented using Node-compatible P-256 ECDH, HKDF-SHA256
165
+ and AES-128-GCM. Fresh sender keys and salt are generated for each new message.
166
+ The app's `NotificationShared/NotificationCrypto.swift` uses the same RFC record.
167
+ Both implementations reproduce the published RFC vector byte-for-byte.
168
+
169
+ Authenticated application JSON contains:
170
+
171
+ ```text
172
+ version, subscriptionID, eventID, installationID, serverID,
173
+ kind (alert | live_activity), producer (server), issuedAt, expiresAt,
174
+ sessionID, directory, content
175
+ + activityID, event (update | end), timestamp for Live Activities
176
+ ```
177
+
178
+ `content` contains bounded `title`, `project`, `phase`, `detail`, `agents`, `shells`,
179
+ `waiting`, optional `cost` / `startedAt`, and `updatedAt`. Freshness is at most one
180
+ hour with 30 seconds allowed future skew. The serialized plaintext has a 2400-byte
181
+ budget; directories are preserved exactly (max 1024 UTF-8 bytes), never truncated
182
+ into another binding. No server URL is needed in the payload.
183
+
184
+ The relay cannot decrypt this content. Lifecycle fields needed by ActivityKit
185
+ remain visible to APNs/the relay. Ordinary NSE and encrypted Widget implementations
186
+ are in this repository; real APNs and locked-device behavior still need signed
187
+ hardware validation. Remote push-to-start is outside v1; activities start in the app.
188
+
189
+ ## Verify
190
+
191
+ ```sh
192
+ bun run typecheck
193
+ bun test
194
+ bun run build
195
+ python3 scripts/check-upstream.py
196
+ # macOS + Rust/Xcode: unmodified upstream → plugin → real Rust relay → production Swift receiver
197
+ python3 scripts/check-upstream.py --rust-relay --swift
198
+ ```
199
+
200
+ The upstream check creates isolated HOME/config/data/database directories. It loads
201
+ the built plugin through global config, creates disposable sessions/forms without
202
+ any model prompts, tests multiple locations, restarts the host and revokes enrollment.
203
+ The optional Rust target uses the production relay router, storage and APNs payload
204
+ construction with captured APNs output. Swift then decrypts and validates those exact
205
+ envelopes using the production shared receiver. No Apple credentials or existing
206
+ OpenCode sessions are involved. The Rust capture executable is a development example,
207
+ not a mode in the deployed relay binary.
208
+
209
+ The recorded verification scope and remaining hardware checks are in
210
+ [`VERIFICATION.md`](VERIFICATION.md).
@@ -0,0 +1,70 @@
1
+ # Verification — initial plugin / app integration
2
+
3
+ ## 0.1.1 distribution check
4
+
5
+ `@opencode/plugin` is a runtime dependency. The 0.1.0 CLI-install path resolved
6
+ its peer dependency, but the tested SDK's automatic package-cache loader did
7
+ not; that path could report `Cannot find module '@opencode/plugin'`.
8
+
9
+ The 0.1.1 npm tarball passed `python3 ../scripts/mock-plugin-registry.py`: a
10
+ loopback registry served the packed artifact and the real upstream SDK resolved
11
+ its dependency graph in a fresh home. The complete Rust relay/Swift receiver
12
+ pipeline accepted six encrypted envelopes. No model or Apple push was used.
13
+
14
+ ## Passed
15
+
16
+ - TypeScript typecheck and distributable build.
17
+ - Thirteen Bun tests (75 assertions), rechecked on 2026-11-01: published RFC 8291 encryption/decryption,
18
+ tamper/key rejection, source/device binding, persistent byte-identical retry,
19
+ revocation/credential revision, parent/child terminal aggregation, activity end,
20
+ child-session activity completion while its parent continues, relay origin validation,
21
+ and payment-expiry suppression with terminal cleanup.
22
+ - `npm pack --dry-run`: publishable JS/declaration/README entrypoints only; no
23
+ development keys, fixtures, dependencies, databases or app data bundled.
24
+ - Installed through ordinary `plugins` configuration on **published upstream
25
+ `@opencode/sdk@2.0.26` / Bun 1.4.0**, without fork changes. The test uses the
26
+ upstream HTTP router, plugin loader, RPC registry, storage and form event.
27
+ - The scoped RPC ID `@jdscript/opencode-notify/mobile-push` also passed the complete
28
+ upstream/Rust/Swift pipeline with the SDK's encoded single-segment routing.
29
+ - Two project Locations produce one alert each. Separate simultaneous SDK hosts
30
+ cannot see each other's subscription/key state. Restart restores the subscription,
31
+ and unsubscribe changes its state to revoked.
32
+ - The same upstream test connected to the Rust relay's production HTTP router,
33
+ SQLite and APNs payload constructor. The current pass captured **six encrypted
34
+ envelopes**, including automatic workspace aggregation. Production Swift receiver code
35
+ decrypted all six and verified subscription/server/installation/session/activity
36
+ binding, alert replay handling and repeat Widget rendering.
37
+ - The iOS app + Notification Service Extension + Live Activity extension compiled
38
+ in a signed Debug simulator build. A second build from an isolated notification-only
39
+ candidate also passed, establishing that prior uncommitted composer/inbox changes
40
+ are not build dependencies. Effective Keychain/App Group entitlements and signatures
41
+ were checked. Swift tests cover the `development` entitlement → `sandbox` relay
42
+ environment mapping.
43
+
44
+ The upstream pipeline uses fresh temporary HOME/config/data/database directories,
45
+ disposable sessions and public form creation. **Zero model prompts.** Temporary
46
+ hosts and capture processes are closed by the driver.
47
+
48
+ ## Run
49
+
50
+ ```sh
51
+ bun install --frozen-lockfile
52
+ bun run build
53
+ bun run typecheck
54
+ bun test
55
+ python3 scripts/check-upstream.py --rust-relay --swift
56
+ ```
57
+
58
+ The final command needs Rust and Xcode's macOS Swift compiler. APNs output is captured
59
+ by a development-only executable; the actual Apple transport is separately covered
60
+ by the relay's HTTP/2/signature test. No Apple credentials are needed for this check.
61
+
62
+ ## Not established by these checks
63
+
64
+ Real Apple delivery, iOS APNs token registration against a provisioned app, NSE
65
+ invocation while locked, remote Widget rendering while suspended/terminated, and
66
+ real device unpair/recovery fault injection remain hardware integration work.
67
+ Device builds need matching capabilities/provisioning for all three targets.
68
+ Installing this package does not enroll a device or configure sender credentials;
69
+ Kiteyard performs that handoff through the authenticated server connection.
70
+ No user's running OpenCode instance is used as a test fixture.
@@ -0,0 +1,8 @@
1
+ /** RFC 8291 + RFC 8188 single-record aes128gcm. All keys stay outside the relay. */
2
+ export declare function encrypt(plaintext: Uint8Array, publicKey: string, authSecret: string, fixture?: {
3
+ privateKey: Uint8Array;
4
+ salt: Uint8Array;
5
+ }): string;
6
+ /** Also used by independent interoperability fixtures; not invoked by the sender at runtime. */
7
+ export declare function decrypt(ciphertext: string, privateKey: string, authSecret: string): Buffer<ArrayBuffer>;
8
+ export declare function decode(value: string, length?: number): Buffer<ArrayBuffer>;
package/dist/crypto.js ADDED
@@ -0,0 +1,63 @@
1
+ import { createCipheriv, createDecipheriv, createECDH, hkdfSync, randomBytes } from "node:crypto";
2
+ /** RFC 8291 + RFC 8188 single-record aes128gcm. All keys stay outside the relay. */
3
+ export function encrypt(plaintext, publicKey, authSecret, fixture) {
4
+ const recipient = decode(publicKey, 65);
5
+ const auth = decode(authSecret, 16);
6
+ if (recipient[0] !== 4)
7
+ throw new Error("invalid_recipient_key");
8
+ const sender = createECDH("prime256v1");
9
+ if (fixture)
10
+ sender.setPrivateKey(fixture.privateKey);
11
+ else
12
+ sender.generateKeys();
13
+ const pub = sender.getPublicKey();
14
+ const salt = fixture ? Buffer.from(fixture.salt) : randomBytes(16);
15
+ if (salt.length !== 16)
16
+ throw new Error("invalid_salt");
17
+ const keys = derive(sender.computeSecret(recipient), auth, recipient, pub, salt);
18
+ const cipher = createCipheriv("aes-128-gcm", keys.key, keys.nonce);
19
+ const encrypted = Buffer.concat([
20
+ cipher.update(Buffer.concat([plaintext, Buffer.from([2])])),
21
+ cipher.final(),
22
+ cipher.getAuthTag(),
23
+ ]);
24
+ const header = Buffer.alloc(21);
25
+ salt.copy(header);
26
+ header.writeUInt32BE(Math.max(4096, encrypted.length + 1), 16);
27
+ header[20] = pub.length;
28
+ return Buffer.concat([header, pub, encrypted]).toString("base64url");
29
+ }
30
+ /** Also used by independent interoperability fixtures; not invoked by the sender at runtime. */
31
+ export function decrypt(ciphertext, privateKey, authSecret) {
32
+ const record = decode(ciphertext);
33
+ if (record.length < 103 || record.length > 4096 || record[20] !== 65 || record[21] !== 4)
34
+ throw new Error("invalid_record");
35
+ const receiver = createECDH("prime256v1");
36
+ receiver.setPrivateKey(decode(privateKey, 32));
37
+ const encrypted = record.subarray(86);
38
+ if (record.readUInt32BE(16) <= encrypted.length)
39
+ throw new Error("invalid_record_size");
40
+ const keys = derive(receiver.computeSecret(record.subarray(21, 86)), decode(authSecret, 16), receiver.getPublicKey(), record.subarray(21, 86), record.subarray(0, 16));
41
+ const decipher = createDecipheriv("aes-128-gcm", keys.key, keys.nonce);
42
+ decipher.setAuthTag(encrypted.subarray(-16));
43
+ const result = Buffer.concat([decipher.update(encrypted.subarray(0, -16)), decipher.final()]);
44
+ const delimiter = result.findLastIndex((byte) => byte !== 0);
45
+ if (delimiter < 0 || result[delimiter] !== 2)
46
+ throw new Error("invalid_record_delimiter");
47
+ return result.subarray(0, delimiter);
48
+ }
49
+ export function decode(value, length) {
50
+ if (!/^[a-zA-Z0-9_-]+$/u.test(value))
51
+ throw new Error("invalid_base64url");
52
+ const bytes = Buffer.from(value, "base64url");
53
+ if (bytes.toString("base64url") !== value || (length !== undefined && bytes.length !== length))
54
+ throw new Error("invalid_key_length");
55
+ return bytes;
56
+ }
57
+ function derive(secret, auth, receiver, sender, salt) {
58
+ const ikm = Buffer.from(hkdfSync("sha256", secret, auth, Buffer.concat([Buffer.from("WebPush: info\0"), receiver, sender]), 32));
59
+ return {
60
+ key: Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16)),
61
+ nonce: Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12)),
62
+ };
63
+ }
@@ -0,0 +1,3 @@
1
+ import { Plugin } from "@opencode/plugin";
2
+ declare const _default: Plugin.Plugin;
3
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,96 @@
1
+ import { Plugin } from "@opencode/plugin";
2
+ import { Definition } from "./rpc.js";
3
+ import { Sender, SenderError } from "./sender.js";
4
+ const registryKey = Symbol.for("@jdscript/opencode-notify/runtime/v1");
5
+ // The host can import a fresh module copy for each Location. Module-local state alone
6
+ // duplicates pushes. App metadata is shared within one host; distinct SDK hosts stay isolated.
7
+ const registry = globalThis;
8
+ const runtimes = (registry[registryKey] ??= new WeakMap());
9
+ /** Location plugin instances share one stream consumer and sender per host. */
10
+ function makePlugin() {
11
+ return Plugin.define({
12
+ id: "opencode.mobile-push",
13
+ async setup(ctx) {
14
+ if (typeof ctx.rpc?.register !== "function" || typeof ctx.storage?.scan !== "function") {
15
+ throw new Error("Mobile notifications require OpenCode v2 plugin RPC and storage APIs (tested on 2.0.26).");
16
+ }
17
+ const shared = runtimes.get(ctx.app) ?? { contexts: new Set() };
18
+ runtimes.set(ctx.app, shared);
19
+ const contexts = shared.contexts;
20
+ contexts.add(ctx);
21
+ shared.sender ??= (async () => {
22
+ const runtime = new Sender(ctx.storage, (sessionID) => {
23
+ const current = contexts.values().next().value;
24
+ if (!current)
25
+ return Promise.reject(new SenderError("plugin_stopped"));
26
+ return current.session.get({ sessionID });
27
+ }, (sessionID) => {
28
+ const current = contexts.values().next().value;
29
+ return current ? current.session.wait({ sessionID }) : Promise.reject(new SenderError("plugin_stopped"));
30
+ });
31
+ await runtime.start();
32
+ return runtime;
33
+ })();
34
+ const runtime = await shared.sender;
35
+ const registration = await ctx.rpc.register(Definition, {
36
+ info: async () => ({ version: 1, encoding: "aes128gcm", applicationVersion: 1, activityScopes: ["session", "server", "workspace"] }),
37
+ subscribe: async (input, context) => runtime
38
+ .subscribe(input)
39
+ .catch((error) => context.error("invalid", "Notification setup failed", { code: code(error) })),
40
+ status: async (input, context) => {
41
+ try {
42
+ return runtime.status(input.subscriptionID);
43
+ }
44
+ catch (error) {
45
+ return context.error("invalid", "Notification subscription unavailable", { code: code(error) });
46
+ }
47
+ },
48
+ unsubscribe: async (input, context) => runtime
49
+ .unsubscribe(input.subscriptionID)
50
+ .catch((error) => context.error("invalid", "Notification removal failed", { code: code(error) })),
51
+ attach: async (input, context) => runtime
52
+ .attach(input)
53
+ .catch((error) => context.error("invalid", "Activity attachment failed", { code: code(error) })),
54
+ detach: async (input, context) => runtime
55
+ .detach(input.subscriptionID, input.activityID)
56
+ .catch((error) => context.error("invalid", "Activity removal failed", { code: code(error) })),
57
+ });
58
+ const controller = new AbortController();
59
+ const events = (async () => {
60
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
61
+ if (controller.signal.aborted)
62
+ return;
63
+ if (contexts.values().next().value !== ctx)
64
+ continue;
65
+ await runtime.onEvent(event).catch(() => {
66
+ runtime.observationUnavailable();
67
+ console.warn("[mobile-push] activity observation unavailable");
68
+ });
69
+ }
70
+ if (!controller.signal.aborted)
71
+ runtime.observationUnavailable();
72
+ })().catch(() => {
73
+ if (!controller.signal.aborted) {
74
+ runtime.observationUnavailable();
75
+ console.warn("[mobile-push] event stream ended");
76
+ }
77
+ });
78
+ return async () => {
79
+ controller.abort();
80
+ contexts.delete(ctx);
81
+ const stopping = contexts.size === 0 ? runtime.stop() : Promise.resolve();
82
+ if (contexts.size === 0)
83
+ runtimes.delete(ctx.app);
84
+ await registration.dispose();
85
+ await events;
86
+ await stopping;
87
+ };
88
+ },
89
+ });
90
+ }
91
+ function code(error) {
92
+ return error instanceof Error && error.name === "MobilePushError" && "code" in error && typeof error.code === "string"
93
+ ? error.code
94
+ : "sender_unavailable";
95
+ }
96
+ export default makePlugin();
package/dist/rpc.d.ts ADDED
@@ -0,0 +1,199 @@
1
+ import { z } from "zod";
2
+ export declare const UUID: z.ZodString;
3
+ export declare const Subscribe: z.ZodObject<{
4
+ subscriptionID: z.ZodString;
5
+ revision: z.ZodNumber;
6
+ relayURL: z.ZodString;
7
+ sendToken: z.ZodString;
8
+ expiresAt: z.ZodNumber;
9
+ serverID: z.ZodString;
10
+ installationID: z.ZodString;
11
+ recipientPublicKey: z.ZodString;
12
+ authSecret: z.ZodString;
13
+ preferences: z.ZodObject<{
14
+ completed: z.ZodBoolean;
15
+ requests: z.ZodBoolean;
16
+ failures: z.ZodBoolean;
17
+ }, z.core.$strict>;
18
+ }, z.core.$strict>;
19
+ export type Subscribe = z.infer<typeof Subscribe>;
20
+ export declare const Reference: z.ZodObject<{
21
+ subscriptionID: z.ZodString;
22
+ }, z.core.$strict>;
23
+ export declare const Route: z.ZodObject<{
24
+ subscriptionID: z.ZodString;
25
+ activityID: z.ZodString;
26
+ }, z.core.$strict>;
27
+ export declare const Attach: z.ZodObject<{
28
+ subscriptionID: z.ZodString;
29
+ activityID: z.ZodString;
30
+ sessionID: z.ZodString;
31
+ expiresAt: z.ZodNumber;
32
+ scope: z.ZodOptional<z.ZodEnum<{
33
+ session: "session";
34
+ server: "server";
35
+ workspace: "workspace";
36
+ }>>;
37
+ serverName: z.ZodOptional<z.ZodString>;
38
+ seedComplete: z.ZodOptional<z.ZodBoolean>;
39
+ seeds: z.ZodOptional<z.ZodArray<z.ZodObject<{
40
+ sessionID: z.ZodString;
41
+ running: z.ZodBoolean;
42
+ waiting: z.ZodArray<z.ZodString>;
43
+ }, z.core.$strict>>>;
44
+ shells: z.ZodOptional<z.ZodArray<z.ZodObject<{
45
+ id: z.ZodString;
46
+ sessionID: z.ZodOptional<z.ZodString>;
47
+ }, z.core.$strict>>>;
48
+ }, z.core.$strict>;
49
+ export type Attach = z.infer<typeof Attach>;
50
+ export declare const Status: z.ZodObject<{
51
+ subscriptionID: z.ZodString;
52
+ revision: z.ZodNumber;
53
+ expiresAt: z.ZodNumber;
54
+ state: z.ZodEnum<{
55
+ active: "active";
56
+ revoked: "revoked";
57
+ expired: "expired";
58
+ unavailable: "unavailable";
59
+ }>;
60
+ }, z.core.$strict>;
61
+ export declare const RPC_ID = "@jdscript/opencode-notify/mobile-push";
62
+ export declare const Definition: {
63
+ readonly id: "@jdscript/opencode-notify/mobile-push";
64
+ readonly events: {};
65
+ readonly methods: {
66
+ readonly info: {
67
+ readonly input: z.ZodObject<{}, z.core.$strict>;
68
+ readonly output: z.ZodObject<{
69
+ version: z.ZodLiteral<1>;
70
+ encoding: z.ZodLiteral<"aes128gcm">;
71
+ applicationVersion: z.ZodLiteral<1>;
72
+ activityScopes: z.ZodOptional<z.ZodArray<z.ZodEnum<{
73
+ session: "session";
74
+ server: "server";
75
+ workspace: "workspace";
76
+ }>>>;
77
+ }, z.core.$strict>;
78
+ readonly errors: {
79
+ invalid: z.ZodObject<{
80
+ code: z.ZodString;
81
+ }, z.core.$strict>;
82
+ };
83
+ };
84
+ readonly subscribe: {
85
+ readonly input: z.ZodObject<{
86
+ subscriptionID: z.ZodString;
87
+ revision: z.ZodNumber;
88
+ relayURL: z.ZodString;
89
+ sendToken: z.ZodString;
90
+ expiresAt: z.ZodNumber;
91
+ serverID: z.ZodString;
92
+ installationID: z.ZodString;
93
+ recipientPublicKey: z.ZodString;
94
+ authSecret: z.ZodString;
95
+ preferences: z.ZodObject<{
96
+ completed: z.ZodBoolean;
97
+ requests: z.ZodBoolean;
98
+ failures: z.ZodBoolean;
99
+ }, z.core.$strict>;
100
+ }, z.core.$strict>;
101
+ readonly output: z.ZodObject<{
102
+ subscriptionID: z.ZodString;
103
+ revision: z.ZodNumber;
104
+ expiresAt: z.ZodNumber;
105
+ state: z.ZodEnum<{
106
+ active: "active";
107
+ revoked: "revoked";
108
+ expired: "expired";
109
+ unavailable: "unavailable";
110
+ }>;
111
+ }, z.core.$strict>;
112
+ readonly errors: {
113
+ invalid: z.ZodObject<{
114
+ code: z.ZodString;
115
+ }, z.core.$strict>;
116
+ };
117
+ };
118
+ readonly unsubscribe: {
119
+ readonly input: z.ZodObject<{
120
+ subscriptionID: z.ZodString;
121
+ }, z.core.$strict>;
122
+ readonly output: z.ZodObject<{
123
+ ok: z.ZodLiteral<true>;
124
+ }, z.core.$strict>;
125
+ readonly errors: {
126
+ invalid: z.ZodObject<{
127
+ code: z.ZodString;
128
+ }, z.core.$strict>;
129
+ };
130
+ };
131
+ readonly status: {
132
+ readonly input: z.ZodObject<{
133
+ subscriptionID: z.ZodString;
134
+ }, z.core.$strict>;
135
+ readonly output: z.ZodObject<{
136
+ subscriptionID: z.ZodString;
137
+ revision: z.ZodNumber;
138
+ expiresAt: z.ZodNumber;
139
+ state: z.ZodEnum<{
140
+ active: "active";
141
+ revoked: "revoked";
142
+ expired: "expired";
143
+ unavailable: "unavailable";
144
+ }>;
145
+ }, z.core.$strict>;
146
+ readonly errors: {
147
+ invalid: z.ZodObject<{
148
+ code: z.ZodString;
149
+ }, z.core.$strict>;
150
+ };
151
+ };
152
+ readonly attach: {
153
+ readonly input: z.ZodObject<{
154
+ subscriptionID: z.ZodString;
155
+ activityID: z.ZodString;
156
+ sessionID: z.ZodString;
157
+ expiresAt: z.ZodNumber;
158
+ scope: z.ZodOptional<z.ZodEnum<{
159
+ session: "session";
160
+ server: "server";
161
+ workspace: "workspace";
162
+ }>>;
163
+ serverName: z.ZodOptional<z.ZodString>;
164
+ seedComplete: z.ZodOptional<z.ZodBoolean>;
165
+ seeds: z.ZodOptional<z.ZodArray<z.ZodObject<{
166
+ sessionID: z.ZodString;
167
+ running: z.ZodBoolean;
168
+ waiting: z.ZodArray<z.ZodString>;
169
+ }, z.core.$strict>>>;
170
+ shells: z.ZodOptional<z.ZodArray<z.ZodObject<{
171
+ id: z.ZodString;
172
+ sessionID: z.ZodOptional<z.ZodString>;
173
+ }, z.core.$strict>>>;
174
+ }, z.core.$strict>;
175
+ readonly output: z.ZodObject<{
176
+ ok: z.ZodLiteral<true>;
177
+ }, z.core.$strict>;
178
+ readonly errors: {
179
+ invalid: z.ZodObject<{
180
+ code: z.ZodString;
181
+ }, z.core.$strict>;
182
+ };
183
+ };
184
+ readonly detach: {
185
+ readonly input: z.ZodObject<{
186
+ subscriptionID: z.ZodString;
187
+ activityID: z.ZodString;
188
+ }, z.core.$strict>;
189
+ readonly output: z.ZodObject<{
190
+ ok: z.ZodLiteral<true>;
191
+ }, z.core.$strict>;
192
+ readonly errors: {
193
+ invalid: z.ZodObject<{
194
+ code: z.ZodString;
195
+ }, z.core.$strict>;
196
+ };
197
+ };
198
+ };
199
+ };