@frockbot/plugin-mobile-clipboard 0.0.0 → 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/frockbot.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "mobile-clipboard",
4
+ "displayName": "Mobile Clipboard",
5
+ "version": "0.0.1",
6
+ "contributions": {
7
+ "mobile": "./mobile"
8
+ },
9
+ "permissions": ["mobile:clipboard:read", "mobile:clipboard:write"]
10
+ }
package/package.json CHANGED
@@ -1,14 +1,37 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-mobile-clipboard",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./mobile": "./src/mobile.ts",
9
+ "./manifest": "./src/manifest.ts",
10
+ "./frockbot.json": "./frockbot.json",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "frockbot": {
14
+ "manifest": "./frockbot.json"
15
+ },
16
+ "scripts": {
17
+ "test": "bun test src",
18
+ "typecheck": "tsc --noEmit -p tsconfig.json"
19
+ },
20
+ "dependencies": {
21
+ "@frockbot/mobile-core": "0.1.1",
22
+ "cordis": "4.0.0-rc.8"
23
+ },
24
+ "devDependencies": {
25
+ "@frockbot/plugin-testkit": "0.1.1",
26
+ "@types/bun": "1.4.0",
27
+ "typescript": "^7.0.2"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
6
32
  "repository": {
7
33
  "type": "git",
8
34
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
35
  "directory": "packages/plugin-mobile-clipboard"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
36
  }
14
37
  }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./mobile.js";
2
+ export { default as mobileClipboardManifest } from "./manifest.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
@@ -0,0 +1,168 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ MobileClipboardCapability,
4
+ MobileCommandRegistry,
5
+ } from "@frockbot/mobile-core";
6
+ import {
7
+ createPluginHarness,
8
+ verifyPluginPackage,
9
+ } from "@frockbot/plugin-testkit";
10
+ import manifest from "../frockbot.json" with { type: "json" };
11
+ import packageJson from "../package.json" with { type: "json" };
12
+ import mobileClipboardPlugin, {
13
+ MAX_CLIPBOARD_TEXT_LENGTH,
14
+ READ_CLIPBOARD_TEXT_COMMAND,
15
+ type ReadClipboardTextResult,
16
+ WRITE_CLIPBOARD_TEXT_COMMAND,
17
+ type WriteClipboardTextResult,
18
+ } from "./mobile.js";
19
+
20
+ class FakeClipboard extends MobileClipboardCapability {
21
+ text = "initial";
22
+
23
+ readText(signal: AbortSignal): Promise<string> {
24
+ signal.throwIfAborted();
25
+ return Promise.resolve(this.text);
26
+ }
27
+
28
+ writeText(text: string, signal: AbortSignal): Promise<void> {
29
+ signal.throwIfAborted();
30
+ this.text = text;
31
+ return Promise.resolve();
32
+ }
33
+ }
34
+
35
+ async function createHost(): Promise<{
36
+ harness: Awaited<ReturnType<typeof createPluginHarness>>;
37
+ clipboard: FakeClipboard;
38
+ }> {
39
+ const harness = await createPluginHarness([
40
+ MobileCommandRegistry,
41
+ FakeClipboard,
42
+ ]);
43
+ return {
44
+ harness,
45
+ clipboard: harness.root.mobileClipboard as FakeClipboard,
46
+ };
47
+ }
48
+
49
+ async function expectFailure(
50
+ promise: Promise<unknown>,
51
+ message: string,
52
+ ): Promise<void> {
53
+ let failure: unknown;
54
+ try {
55
+ await promise;
56
+ } catch (error) {
57
+ failure = error;
58
+ }
59
+ expect(failure).toBeInstanceOf(Error);
60
+ expect(failure instanceof Error ? failure.message : "").toContain(message);
61
+ }
62
+
63
+ describe("mobile clipboard plugin", () => {
64
+ test("registers read and write commands over the granted capability", async () => {
65
+ const { harness, clipboard } = await createHost();
66
+ await harness.mount(mobileClipboardPlugin);
67
+
68
+ expect(
69
+ await harness.root.mobileCommands.invoke<ReadClipboardTextResult>(
70
+ READ_CLIPBOARD_TEXT_COMMAND,
71
+ {},
72
+ ),
73
+ ).toEqual({ text: "initial" });
74
+ expect(
75
+ await harness.root.mobileCommands.invoke<WriteClipboardTextResult>(
76
+ WRITE_CLIPBOARD_TEXT_COMMAND,
77
+ { text: "updated" },
78
+ ),
79
+ ).toEqual({ written: true });
80
+ expect(clipboard.text).toBe("updated");
81
+ await harness.dispose();
82
+ });
83
+
84
+ test("preserves exact clipboard text, including an empty string", async () => {
85
+ const { harness, clipboard } = await createHost();
86
+ await harness.mount(mobileClipboardPlugin);
87
+
88
+ await harness.root.mobileCommands.invoke(WRITE_CLIPBOARD_TEXT_COMMAND, {
89
+ text: "",
90
+ });
91
+ expect(clipboard.text).toBe("");
92
+ await harness.dispose();
93
+ });
94
+
95
+ test("unregisters both commands on disposal", async () => {
96
+ const { harness } = await createHost();
97
+ const fiber = await harness.mount(mobileClipboardPlugin);
98
+
99
+ await fiber.dispose();
100
+ await expectFailure(
101
+ harness.root.mobileCommands.invoke(READ_CLIPBOARD_TEXT_COMMAND, {}),
102
+ "is unavailable",
103
+ );
104
+ await expectFailure(
105
+ harness.root.mobileCommands.invoke(WRITE_CLIPBOARD_TEXT_COMMAND, {
106
+ text: "not written",
107
+ }),
108
+ "is unavailable",
109
+ );
110
+ await harness.dispose();
111
+ });
112
+
113
+ test("rejects malformed writes before invoking the capability", async () => {
114
+ const { harness, clipboard } = await createHost();
115
+ await harness.mount(mobileClipboardPlugin);
116
+
117
+ await expectFailure(
118
+ harness.root.mobileCommands.invoke(WRITE_CLIPBOARD_TEXT_COMMAND, {
119
+ text: 42,
120
+ }),
121
+ "clipboard text must be a string",
122
+ );
123
+ await expectFailure(
124
+ harness.root.mobileCommands.invoke(WRITE_CLIPBOARD_TEXT_COMMAND, {
125
+ text: "x".repeat(MAX_CLIPBOARD_TEXT_LENGTH + 1),
126
+ }),
127
+ "clipboard text must be at most 1000000 characters",
128
+ );
129
+ await expectFailure(
130
+ harness.root.mobileCommands.invoke(READ_CLIPBOARD_TEXT_COMMAND, {
131
+ extra: true,
132
+ }),
133
+ "clipboard input has unknown fields",
134
+ );
135
+ clipboard.text = "x".repeat(MAX_CLIPBOARD_TEXT_LENGTH + 1);
136
+ await expectFailure(
137
+ harness.root.mobileCommands.invoke(READ_CLIPBOARD_TEXT_COMMAND, {}),
138
+ "clipboard text must be at most 1000000 characters",
139
+ );
140
+ expect(clipboard.text.length).toBe(MAX_CLIPBOARD_TEXT_LENGTH + 1);
141
+ await harness.dispose();
142
+ });
143
+
144
+ test("propagates the caller signal to the capability", async () => {
145
+ const { harness, clipboard } = await createHost();
146
+ await harness.mount(mobileClipboardPlugin);
147
+ const controller = new AbortController();
148
+ controller.abort(new Error("cancelled by test"));
149
+
150
+ await expectFailure(
151
+ harness.root.mobileCommands.invoke(
152
+ WRITE_CLIPBOARD_TEXT_COMMAND,
153
+ { text: "not written" },
154
+ controller.signal,
155
+ ),
156
+ "cancelled by test",
157
+ );
158
+ expect(clipboard.text).toBe("initial");
159
+ await harness.dispose();
160
+ });
161
+
162
+ test("satisfies plugin package conventions", () => {
163
+ expect(verifyPluginPackage({ packageJson, manifest })).toMatchObject({
164
+ name: "@frockbot/plugin-mobile-clipboard",
165
+ contributionKinds: ["mobile"],
166
+ });
167
+ });
168
+ });
package/src/mobile.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type {
2
+ MobileCommand,
3
+ MobileClipboardCapability,
4
+ } from "@frockbot/mobile-core";
5
+ import type { Plugin } from "cordis";
6
+
7
+ export const READ_CLIPBOARD_TEXT_COMMAND = "mobile.clipboard.readText";
8
+ export const WRITE_CLIPBOARD_TEXT_COMMAND = "mobile.clipboard.writeText";
9
+ export const MAX_CLIPBOARD_TEXT_LENGTH = 1_000_000;
10
+
11
+ export type ReadClipboardTextInput = Record<string, never>;
12
+
13
+ export interface ReadClipboardTextResult {
14
+ text: string;
15
+ }
16
+
17
+ export interface WriteClipboardTextInput {
18
+ text: string;
19
+ }
20
+
21
+ export interface WriteClipboardTextResult {
22
+ written: true;
23
+ }
24
+
25
+ function inputRecord(
26
+ input: unknown,
27
+ allowedKeys: readonly string[],
28
+ ): Record<string, unknown> {
29
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
30
+ throw new Error("clipboard input must be an object");
31
+ }
32
+ const keys = Reflect.ownKeys(input);
33
+ if (
34
+ keys.some(
35
+ (key) =>
36
+ typeof key !== "string" ||
37
+ !allowedKeys.includes(key) ||
38
+ !Object.prototype.propertyIsEnumerable.call(input, key),
39
+ )
40
+ ) {
41
+ throw new Error("clipboard input has unknown fields");
42
+ }
43
+ return input as Record<string, unknown>;
44
+ }
45
+
46
+ export function decodeReadClipboardTextInput(
47
+ input: unknown,
48
+ ): ReadClipboardTextInput {
49
+ inputRecord(input, []);
50
+ return {};
51
+ }
52
+
53
+ export function decodeWriteClipboardTextInput(
54
+ input: unknown,
55
+ ): WriteClipboardTextInput {
56
+ const record = inputRecord(input, ["text"]);
57
+ if (typeof record.text !== "string") {
58
+ throw new Error("clipboard text must be a string");
59
+ }
60
+ if (record.text.length > MAX_CLIPBOARD_TEXT_LENGTH) {
61
+ throw new Error(
62
+ `clipboard text must be at most ${MAX_CLIPBOARD_TEXT_LENGTH} characters`,
63
+ );
64
+ }
65
+ return { text: record.text };
66
+ }
67
+
68
+ function readCommand(
69
+ clipboard: MobileClipboardCapability,
70
+ ): MobileCommand<ReadClipboardTextInput, ReadClipboardTextResult> {
71
+ return {
72
+ id: READ_CLIPBOARD_TEXT_COMMAND,
73
+ decode: decodeReadClipboardTextInput,
74
+ async execute(_input, context): Promise<ReadClipboardTextResult> {
75
+ const text = await clipboard.readText(context.signal);
76
+ if (text.length > MAX_CLIPBOARD_TEXT_LENGTH) {
77
+ throw new Error(
78
+ `clipboard text must be at most ${MAX_CLIPBOARD_TEXT_LENGTH} characters`,
79
+ );
80
+ }
81
+ return { text };
82
+ },
83
+ };
84
+ }
85
+
86
+ function writeCommand(
87
+ clipboard: MobileClipboardCapability,
88
+ ): MobileCommand<WriteClipboardTextInput, WriteClipboardTextResult> {
89
+ return {
90
+ id: WRITE_CLIPBOARD_TEXT_COMMAND,
91
+ decode: decodeWriteClipboardTextInput,
92
+ async execute(input, context): Promise<WriteClipboardTextResult> {
93
+ await clipboard.writeText(input.text, context.signal);
94
+ return { written: true };
95
+ },
96
+ };
97
+ }
98
+
99
+ export const mobileClipboardPlugin: Plugin.Function = (ctx) => [
100
+ ctx.mobileCommands.register(readCommand(ctx.mobileClipboard)),
101
+ ctx.mobileCommands.register(writeCommand(ctx.mobileClipboard)),
102
+ ];
103
+ mobileClipboardPlugin.inject = ["mobileCommands", "mobileClipboard"];
104
+
105
+ export default mobileClipboardPlugin;
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "resolveJsonModule": true,
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["ES2023", "DOM"],
12
+ "types": ["bun"]
13
+ },
14
+ "include": ["src/**/*.ts"]
15
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @frockbot/plugin-mobile-clipboard
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.