@frockbot/mobile-core 0.0.0 → 0.1.0
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/package.json +20 -6
- package/src/index.test.ts +158 -0
- package/src/index.ts +177 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/mobile-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "bun test src",
|
|
11
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"cordis": "4.0.0-rc.8"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/bun": "1.4.0",
|
|
18
|
+
"typescript": "^7.0.2"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
6
23
|
"repository": {
|
|
7
24
|
"type": "git",
|
|
8
25
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
26
|
"directory": "packages/mobile-core"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
27
|
}
|
|
14
28
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Context } from "cordis";
|
|
3
|
+
import { decodeMobileShareRequest, MobileCommandRegistry } from "./index.js";
|
|
4
|
+
|
|
5
|
+
const roots: Context[] = [];
|
|
6
|
+
|
|
7
|
+
async function createRegistry(): Promise<Context> {
|
|
8
|
+
const root = new Context();
|
|
9
|
+
roots.push(root);
|
|
10
|
+
await root.plugin(MobileCommandRegistry);
|
|
11
|
+
return root;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await Promise.all(roots.splice(0).map((root) => root.fiber.dispose()));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function expectFailure(
|
|
19
|
+
promise: Promise<unknown>,
|
|
20
|
+
message: string,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
let failure: unknown;
|
|
23
|
+
try {
|
|
24
|
+
await promise;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
failure = error;
|
|
27
|
+
}
|
|
28
|
+
expect(failure).toBeInstanceOf(Error);
|
|
29
|
+
expect(failure instanceof Error ? failure.message : "").toContain(message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("MobileCommandRegistry", () => {
|
|
33
|
+
test("decodes and invokes a registered command", async () => {
|
|
34
|
+
const root = await createRegistry();
|
|
35
|
+
root.mobileCommands.register({
|
|
36
|
+
id: "fixture.echo",
|
|
37
|
+
decode(input: unknown): string {
|
|
38
|
+
if (typeof input !== "string") throw new Error("text is required");
|
|
39
|
+
return input;
|
|
40
|
+
},
|
|
41
|
+
execute: (input: string) => Promise.resolve(`echo:${input}`),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(
|
|
45
|
+
await root.mobileCommands.invoke<string>("fixture.echo", "hello"),
|
|
46
|
+
).toBe("echo:hello");
|
|
47
|
+
await expectFailure(
|
|
48
|
+
root.mobileCommands.invoke("fixture.echo", { text: "no" }),
|
|
49
|
+
"text is required",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("removes only the registration that owns the command", async () => {
|
|
54
|
+
const root = await createRegistry();
|
|
55
|
+
const dispose = root.mobileCommands.register({
|
|
56
|
+
id: "fixture.command",
|
|
57
|
+
decode: () => null,
|
|
58
|
+
execute: () => Promise.resolve(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(root.mobileCommands.list()).toEqual([{ id: "fixture.command" }]);
|
|
62
|
+
dispose();
|
|
63
|
+
dispose();
|
|
64
|
+
expect(root.mobileCommands.list()).toEqual([]);
|
|
65
|
+
await expectFailure(
|
|
66
|
+
root.mobileCommands.invoke("fixture.command", null),
|
|
67
|
+
'mobile command "fixture.command" is unavailable',
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("rejects duplicate command ids", async () => {
|
|
72
|
+
const root = await createRegistry();
|
|
73
|
+
const first = {
|
|
74
|
+
id: "fixture.command",
|
|
75
|
+
decode: () => null,
|
|
76
|
+
execute: () => Promise.resolve(),
|
|
77
|
+
};
|
|
78
|
+
root.mobileCommands.register(first);
|
|
79
|
+
|
|
80
|
+
expect(() => root.mobileCommands.register(first)).toThrow(
|
|
81
|
+
'mobile command "fixture.command" is already registered',
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("rejects an empty command id", async () => {
|
|
86
|
+
const root = await createRegistry();
|
|
87
|
+
|
|
88
|
+
expect(() =>
|
|
89
|
+
root.mobileCommands.register({
|
|
90
|
+
id: " ",
|
|
91
|
+
decode: () => null,
|
|
92
|
+
execute: () => Promise.resolve(),
|
|
93
|
+
}),
|
|
94
|
+
).toThrow("mobile command id must not be empty");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("lists registered commands in sorted order", async () => {
|
|
98
|
+
const root = await createRegistry();
|
|
99
|
+
root.mobileCommands.register({
|
|
100
|
+
id: "fixture.second",
|
|
101
|
+
decode: () => null,
|
|
102
|
+
execute: () => Promise.resolve(),
|
|
103
|
+
});
|
|
104
|
+
root.mobileCommands.register({
|
|
105
|
+
id: "fixture.first",
|
|
106
|
+
decode: () => null,
|
|
107
|
+
execute: () => Promise.resolve(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(root.mobileCommands.list()).toEqual([
|
|
111
|
+
{ id: "fixture.first" },
|
|
112
|
+
{ id: "fixture.second" },
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("honors cancellation before execution", async () => {
|
|
117
|
+
const root = await createRegistry();
|
|
118
|
+
let executions = 0;
|
|
119
|
+
root.mobileCommands.register({
|
|
120
|
+
id: "fixture.cancelled",
|
|
121
|
+
decode: () => null,
|
|
122
|
+
execute: () => {
|
|
123
|
+
executions += 1;
|
|
124
|
+
return Promise.resolve();
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
controller.abort(new Error("cancelled by test"));
|
|
129
|
+
|
|
130
|
+
await expectFailure(
|
|
131
|
+
root.mobileCommands.invoke("fixture.cancelled", null, controller.signal),
|
|
132
|
+
"cancelled by test",
|
|
133
|
+
);
|
|
134
|
+
expect(executions).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("decodeMobileShareRequest", () => {
|
|
139
|
+
test("normalizes a share request that carries text", () => {
|
|
140
|
+
expect(
|
|
141
|
+
decodeMobileShareRequest({ title: " Turn ", text: " done ", url: " " }),
|
|
142
|
+
).toEqual({ title: "Turn", text: "done", url: undefined });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("requires an exact request carrying text or url", () => {
|
|
146
|
+
expect(() => decodeMobileShareRequest({ title: "Turn" })).toThrow(
|
|
147
|
+
"share request must include text or url",
|
|
148
|
+
);
|
|
149
|
+
expect(() =>
|
|
150
|
+
decodeMobileShareRequest({ text: "done", extra: true }),
|
|
151
|
+
).toThrow("share request has unknown fields");
|
|
152
|
+
const hidden = { text: "done" };
|
|
153
|
+
Object.defineProperty(hidden, "secret", { value: true });
|
|
154
|
+
expect(() => decodeMobileShareRequest(hidden)).toThrow(
|
|
155
|
+
"share request has unknown fields",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { type Context, Service } from "cordis";
|
|
2
|
+
|
|
3
|
+
export interface MobileCommandContext {
|
|
4
|
+
signal: AbortSignal;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type MobileCommandPayload = object | string | number | boolean | null;
|
|
8
|
+
export type MobileCommandResult = MobileCommandPayload | void;
|
|
9
|
+
|
|
10
|
+
export interface MobileCommand<
|
|
11
|
+
Input extends MobileCommandPayload,
|
|
12
|
+
Output extends MobileCommandResult,
|
|
13
|
+
> {
|
|
14
|
+
id: string;
|
|
15
|
+
decode(input: unknown): Input;
|
|
16
|
+
execute(input: Input, context: MobileCommandContext): Promise<Output>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RegisteredMobileCommand {
|
|
20
|
+
source: object;
|
|
21
|
+
decode(input: unknown): MobileCommandPayload;
|
|
22
|
+
execute(
|
|
23
|
+
input: MobileCommandPayload,
|
|
24
|
+
context: MobileCommandContext,
|
|
25
|
+
): Promise<MobileCommandResult>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MobileCommandSummary {
|
|
29
|
+
id: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class MobileCommandRegistry extends Service {
|
|
33
|
+
private commands = new Map<string, RegisteredMobileCommand>();
|
|
34
|
+
|
|
35
|
+
constructor(ctx: Context) {
|
|
36
|
+
super(ctx, "mobileCommands");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
register<
|
|
40
|
+
Input extends MobileCommandPayload,
|
|
41
|
+
Output extends MobileCommandResult,
|
|
42
|
+
>(command: MobileCommand<Input, Output>): () => void {
|
|
43
|
+
const id = command.id.trim();
|
|
44
|
+
if (!id) throw new Error("mobile command id must not be empty");
|
|
45
|
+
if (this.commands.has(id)) {
|
|
46
|
+
throw new Error(`mobile command "${id}" is already registered`);
|
|
47
|
+
}
|
|
48
|
+
const registered: RegisteredMobileCommand = {
|
|
49
|
+
source: command,
|
|
50
|
+
decode: (input) => command.decode(input),
|
|
51
|
+
execute: (input, context) => command.execute(input as Input, context),
|
|
52
|
+
};
|
|
53
|
+
this.commands.set(id, registered);
|
|
54
|
+
return () => {
|
|
55
|
+
if (this.commands.get(id)?.source === command) this.commands.delete(id);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
list(): MobileCommandSummary[] {
|
|
60
|
+
return [...this.commands.keys()].sort().map((id) => ({ id }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async invoke<Output extends MobileCommandResult = MobileCommandResult>(
|
|
64
|
+
commandId: string,
|
|
65
|
+
input: unknown,
|
|
66
|
+
signal: AbortSignal = new AbortController().signal,
|
|
67
|
+
): Promise<Output> {
|
|
68
|
+
const command = this.commands.get(commandId);
|
|
69
|
+
if (!command) {
|
|
70
|
+
throw new Error(`mobile command "${commandId}" is unavailable`);
|
|
71
|
+
}
|
|
72
|
+
signal.throwIfAborted();
|
|
73
|
+
const decoded = command.decode(input);
|
|
74
|
+
signal.throwIfAborted();
|
|
75
|
+
return (await command.execute(decoded, { signal })) as Output;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type MobileNotificationUrgency = "normal" | "critical";
|
|
80
|
+
|
|
81
|
+
export interface MobileNotificationRequest {
|
|
82
|
+
title: string;
|
|
83
|
+
body?: string;
|
|
84
|
+
urgency: MobileNotificationUrgency;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export abstract class MobileNotificationCapability extends Service {
|
|
88
|
+
constructor(ctx: Context) {
|
|
89
|
+
super(ctx, "mobileNotifications");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
abstract show(
|
|
93
|
+
request: MobileNotificationRequest,
|
|
94
|
+
signal: AbortSignal,
|
|
95
|
+
): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export abstract class MobileClipboardCapability extends Service {
|
|
99
|
+
constructor(ctx: Context) {
|
|
100
|
+
super(ctx, "mobileClipboard");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
abstract readText(signal: AbortSignal): Promise<string>;
|
|
104
|
+
|
|
105
|
+
abstract writeText(text: string, signal: AbortSignal): Promise<void>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface MobileShareRequest {
|
|
109
|
+
title?: string;
|
|
110
|
+
text?: string;
|
|
111
|
+
url?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function exactObject(
|
|
115
|
+
input: unknown,
|
|
116
|
+
allowedKeys: readonly string[],
|
|
117
|
+
label: string,
|
|
118
|
+
): Record<string, unknown> {
|
|
119
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
120
|
+
throw new Error(`${label} must be an object`);
|
|
121
|
+
}
|
|
122
|
+
const keys = Reflect.ownKeys(input);
|
|
123
|
+
if (
|
|
124
|
+
keys.some(
|
|
125
|
+
(key) =>
|
|
126
|
+
typeof key !== "string" ||
|
|
127
|
+
!allowedKeys.includes(key) ||
|
|
128
|
+
!Object.prototype.propertyIsEnumerable.call(input, key),
|
|
129
|
+
)
|
|
130
|
+
) {
|
|
131
|
+
throw new Error(`${label} has unknown fields`);
|
|
132
|
+
}
|
|
133
|
+
return input as Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function optionalShareField(
|
|
137
|
+
record: Record<string, unknown>,
|
|
138
|
+
key: string,
|
|
139
|
+
): string | undefined {
|
|
140
|
+
const value = record[key];
|
|
141
|
+
if (value === undefined) return undefined;
|
|
142
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
|
143
|
+
const normalized = value.trim();
|
|
144
|
+
return normalized ? normalized : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function decodeMobileShareRequest(input: unknown): MobileShareRequest {
|
|
148
|
+
const record = exactObject(input, ["title", "text", "url"], "share request");
|
|
149
|
+
const title = optionalShareField(record, "title");
|
|
150
|
+
const text = optionalShareField(record, "text");
|
|
151
|
+
const url = optionalShareField(record, "url");
|
|
152
|
+
if (!text && !url) {
|
|
153
|
+
throw new Error("share request must include text or url");
|
|
154
|
+
}
|
|
155
|
+
return { title, text, url };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export abstract class MobileShareCapability extends Service {
|
|
159
|
+
constructor(ctx: Context) {
|
|
160
|
+
super(ctx, "mobileShare");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
abstract share(
|
|
164
|
+
request: MobileShareRequest,
|
|
165
|
+
signal: AbortSignal,
|
|
166
|
+
): Promise<void>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Cordis context services exposed to mobile contribution plugins.
|
|
170
|
+
declare module "cordis" {
|
|
171
|
+
interface Context {
|
|
172
|
+
mobileCommands: MobileCommandRegistry;
|
|
173
|
+
mobileNotifications: MobileNotificationCapability;
|
|
174
|
+
mobileClipboard: MobileClipboardCapability;
|
|
175
|
+
mobileShare: MobileShareCapability;
|
|
176
|
+
}
|
|
177
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"lib": ["ES2023", "DOM"],
|
|
11
|
+
"types": ["bun"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|
package/README.md
DELETED