@dxos/plugin-crx 0.0.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.
Files changed (168) hide show
  1. package/LICENSE +105 -0
  2. package/PLUGIN.mdl +599 -0
  3. package/README.md +25 -0
  4. package/dist/lib/CrxCapabilities.mjs +21 -0
  5. package/dist/lib/CrxCapabilities.mjs.map +1 -0
  6. package/dist/lib/CrxEvents.mjs +15 -0
  7. package/dist/lib/CrxEvents.mjs.map +1 -0
  8. package/dist/lib/CrxOperation.mjs +50 -0
  9. package/dist/lib/CrxOperation.mjs.map +1 -0
  10. package/dist/lib/CrxPlugin.mjs +16 -0
  11. package/dist/lib/CrxPlugin.mjs.map +1 -0
  12. package/dist/lib/PageAction.mjs +35 -0
  13. package/dist/lib/PageAction.mjs.map +1 -0
  14. package/dist/lib/Settings.mjs +84 -0
  15. package/dist/lib/Settings.mjs.map +1 -0
  16. package/dist/lib/capabilities.mjs +41 -0
  17. package/dist/lib/capabilities.mjs.map +1 -0
  18. package/dist/lib/capabilities.node.mjs +32 -0
  19. package/dist/lib/capabilities.node.mjs.map +1 -0
  20. package/dist/lib/capabilities.workerd.mjs +32 -0
  21. package/dist/lib/capabilities.workerd.mjs.map +1 -0
  22. package/dist/lib/chunk-add-note-from-snapshot.mjs +15 -0
  23. package/dist/lib/chunk-add-note-from-snapshot.mjs.map +1 -0
  24. package/dist/lib/chunk-add-organization-from-snapshot.mjs +15 -0
  25. package/dist/lib/chunk-add-organization-from-snapshot.mjs.map +1 -0
  26. package/dist/lib/chunk-add-person-from-snapshot.mjs +15 -0
  27. package/dist/lib/chunk-add-person-from-snapshot.mjs.map +1 -0
  28. package/dist/lib/chunk-install-page-actions.mjs +284 -0
  29. package/dist/lib/chunk-install-page-actions.mjs.map +1 -0
  30. package/dist/lib/chunk-mapping.mjs +81 -0
  31. package/dist/lib/chunk-mapping.mjs.map +1 -0
  32. package/dist/lib/chunk-operation-handler.mjs +12 -0
  33. package/dist/lib/chunk-operation-handler.mjs.map +1 -0
  34. package/dist/lib/chunk-page-action-provider.mjs +41 -0
  35. package/dist/lib/chunk-page-action-provider.mjs.map +1 -0
  36. package/dist/lib/chunk-react-surface.mjs +18 -0
  37. package/dist/lib/chunk-react-surface.mjs.map +1 -0
  38. package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
  39. package/dist/lib/chunk-settings.mjs +28 -0
  40. package/dist/lib/chunk-settings.mjs.map +1 -0
  41. package/dist/lib/containers.mjs +80 -0
  42. package/dist/lib/containers.mjs.map +1 -0
  43. package/dist/lib/index.mjs +4 -0
  44. package/dist/lib/meta.mjs +34 -0
  45. package/dist/lib/meta.mjs.map +1 -0
  46. package/dist/lib/operations.mjs +13 -0
  47. package/dist/lib/operations.mjs.map +1 -0
  48. package/dist/lib/plugin.mjs +9 -0
  49. package/dist/lib/plugin.mjs.map +1 -0
  50. package/dist/lib/translations.mjs +22 -0
  51. package/dist/lib/translations.mjs.map +1 -0
  52. package/dist/lib/types.mjs +6 -0
  53. package/dist/lib/util.mjs +63 -0
  54. package/dist/lib/util.mjs.map +1 -0
  55. package/dist/types/dx.config.d.ts +28 -0
  56. package/dist/types/dx.config.d.ts.map +1 -0
  57. package/dist/types/src/CrxPlugin.d.ts +37 -0
  58. package/dist/types/src/CrxPlugin.d.ts.map +1 -0
  59. package/dist/types/src/capabilities/gen/node.d.ts +9 -0
  60. package/dist/types/src/capabilities/gen/node.d.ts.map +1 -0
  61. package/dist/types/src/capabilities/gen/workerd.d.ts +9 -0
  62. package/dist/types/src/capabilities/gen/workerd.d.ts.map +1 -0
  63. package/dist/types/src/capabilities/index.d.ts +9 -0
  64. package/dist/types/src/capabilities/index.d.ts.map +1 -0
  65. package/dist/types/src/capabilities/install-page-actions.d.ts +5 -0
  66. package/dist/types/src/capabilities/install-page-actions.d.ts.map +1 -0
  67. package/dist/types/src/capabilities/operation-handler.d.ts +5 -0
  68. package/dist/types/src/capabilities/operation-handler.d.ts.map +1 -0
  69. package/dist/types/src/capabilities/page-action-provider.d.ts +5 -0
  70. package/dist/types/src/capabilities/page-action-provider.d.ts.map +1 -0
  71. package/dist/types/src/capabilities/react-surface.d.ts +5 -0
  72. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -0
  73. package/dist/types/src/capabilities/settings.d.ts +10 -0
  74. package/dist/types/src/capabilities/settings.d.ts.map +1 -0
  75. package/dist/types/src/containers/CrxSettings/CrxSettings.d.ts +10 -0
  76. package/dist/types/src/containers/CrxSettings/CrxSettings.d.ts.map +1 -0
  77. package/dist/types/src/containers/CrxSettings/CrxSettings.stories.d.ts +25 -0
  78. package/dist/types/src/containers/CrxSettings/CrxSettings.stories.d.ts.map +1 -0
  79. package/dist/types/src/containers/CrxSettings/index.d.ts +2 -0
  80. package/dist/types/src/containers/CrxSettings/index.d.ts.map +1 -0
  81. package/dist/types/src/containers/index.d.ts +2 -0
  82. package/dist/types/src/containers/index.d.ts.map +1 -0
  83. package/dist/types/src/index.d.ts +4 -0
  84. package/dist/types/src/index.d.ts.map +1 -0
  85. package/dist/types/src/mapping.d.ts +24 -0
  86. package/dist/types/src/mapping.d.ts.map +1 -0
  87. package/dist/types/src/mapping.test.d.ts +2 -0
  88. package/dist/types/src/mapping.test.d.ts.map +1 -0
  89. package/dist/types/src/meta.d.ts +33 -0
  90. package/dist/types/src/meta.d.ts.map +1 -0
  91. package/dist/types/src/operations/add-note-from-snapshot.d.ts +5 -0
  92. package/dist/types/src/operations/add-note-from-snapshot.d.ts.map +1 -0
  93. package/dist/types/src/operations/add-organization-from-snapshot.d.ts +5 -0
  94. package/dist/types/src/operations/add-organization-from-snapshot.d.ts.map +1 -0
  95. package/dist/types/src/operations/add-person-from-snapshot.d.ts +5 -0
  96. package/dist/types/src/operations/add-person-from-snapshot.d.ts.map +1 -0
  97. package/dist/types/src/operations/index.d.ts +3 -0
  98. package/dist/types/src/operations/index.d.ts.map +1 -0
  99. package/dist/types/src/page-actions.d.ts +39 -0
  100. package/dist/types/src/page-actions.d.ts.map +1 -0
  101. package/dist/types/src/page-actions.test.d.ts +2 -0
  102. package/dist/types/src/page-actions.test.d.ts.map +1 -0
  103. package/dist/types/src/plugin.d.ts +4 -0
  104. package/dist/types/src/plugin.d.ts.map +1 -0
  105. package/dist/types/src/stories/Testbench.stories.d.ts +8 -0
  106. package/dist/types/src/stories/Testbench.stories.d.ts.map +1 -0
  107. package/dist/types/src/translations.d.ts +22 -0
  108. package/dist/types/src/translations.d.ts.map +1 -0
  109. package/dist/types/src/types/CrxCapabilities.d.ts +12 -0
  110. package/dist/types/src/types/CrxCapabilities.d.ts.map +1 -0
  111. package/dist/types/src/types/CrxEvents.d.ts +8 -0
  112. package/dist/types/src/types/CrxEvents.d.ts.map +1 -0
  113. package/dist/types/src/types/CrxOperation.d.ts +108 -0
  114. package/dist/types/src/types/CrxOperation.d.ts.map +1 -0
  115. package/dist/types/src/types/PageAction.d.ts +153 -0
  116. package/dist/types/src/types/PageAction.d.ts.map +1 -0
  117. package/dist/types/src/types/Settings.d.ts +25 -0
  118. package/dist/types/src/types/Settings.d.ts.map +1 -0
  119. package/dist/types/src/types/Settings.test.d.ts +2 -0
  120. package/dist/types/src/types/Settings.test.d.ts.map +1 -0
  121. package/dist/types/src/types/index.d.ts +6 -0
  122. package/dist/types/src/types/index.d.ts.map +1 -0
  123. package/dist/types/src/util/index.d.ts +2 -0
  124. package/dist/types/src/util/index.d.ts.map +1 -0
  125. package/dist/types/src/util/pingExtension.d.ts +15 -0
  126. package/dist/types/src/util/pingExtension.d.ts.map +1 -0
  127. package/dist/types/src/util/pingExtension.test.d.ts +2 -0
  128. package/dist/types/src/util/pingExtension.test.d.ts.map +1 -0
  129. package/dist/types/tsconfig.tsbuildinfo +1 -0
  130. package/dx.config.ts +32 -0
  131. package/package.json +162 -0
  132. package/src/CrxPlugin.ts +15 -0
  133. package/src/capabilities/gen/node.ts +43 -0
  134. package/src/capabilities/gen/workerd.ts +43 -0
  135. package/src/capabilities/index.ts +47 -0
  136. package/src/capabilities/install-page-actions.ts +64 -0
  137. package/src/capabilities/operation-handler.ts +16 -0
  138. package/src/capabilities/page-action-provider.ts +47 -0
  139. package/src/capabilities/react-surface.ts +26 -0
  140. package/src/capabilities/settings.ts +36 -0
  141. package/src/containers/CrxSettings/CrxSettings.stories.tsx +99 -0
  142. package/src/containers/CrxSettings/CrxSettings.tsx +100 -0
  143. package/src/containers/CrxSettings/index.ts +5 -0
  144. package/src/containers/index.ts +5 -0
  145. package/src/index.ts +7 -0
  146. package/src/mapping.test.ts +87 -0
  147. package/src/mapping.ts +101 -0
  148. package/src/meta.ts +9 -0
  149. package/src/operations/add-note-from-snapshot.ts +24 -0
  150. package/src/operations/add-organization-from-snapshot.ts +29 -0
  151. package/src/operations/add-person-from-snapshot.ts +29 -0
  152. package/src/operations/index.ts +16 -0
  153. package/src/page-actions.test.ts +164 -0
  154. package/src/page-actions.ts +171 -0
  155. package/src/plugin.tsx +29 -0
  156. package/src/stories/Testbench.stories.tsx +45 -0
  157. package/src/translations.ts +31 -0
  158. package/src/types/CrxCapabilities.ts +26 -0
  159. package/src/types/CrxEvents.ts +16 -0
  160. package/src/types/CrxOperation.ts +54 -0
  161. package/src/types/PageAction.ts +57 -0
  162. package/src/types/Settings.test.ts +30 -0
  163. package/src/types/Settings.ts +101 -0
  164. package/src/types/index.ts +9 -0
  165. package/src/util/index.ts +5 -0
  166. package/src/util/pingExtension.test.ts +69 -0
  167. package/src/util/pingExtension.ts +83 -0
  168. package/src/vite-env.d.ts +10 -0
@@ -0,0 +1,171 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Result from 'effect/Result';
6
+ import * as Schema from 'effect/Schema';
7
+
8
+ import * as Capabilities from '@dxos/app-framework/Capabilities';
9
+ import type * as CapabilityManager from '@dxos/app-framework/CapabilityManager';
10
+ import * as AppSpace from '@dxos/app-toolkit/AppSpace';
11
+ import { type Database } from '@dxos/echo';
12
+ import { type SpaceId } from '@dxos/keys';
13
+ import { log } from '@dxos/log';
14
+ import * as ClientCapabilities from '@dxos/plugin-client/ClientCapabilities';
15
+
16
+ import { CrxCapabilities, PageAction, Settings } from '#types';
17
+
18
+ /**
19
+ * Dependencies for the invoke handler, injected so the handler can be
20
+ * unit-tested without a capability manager (the capability module wires the
21
+ * real implementations).
22
+ */
23
+ export type InvokeDeps = {
24
+ getActions: () => PageAction.PageAction[];
25
+ getSettings: () => Settings.Settings;
26
+ getTarget: () => Database.Database | undefined;
27
+ invoke: (
28
+ operation: PageAction.PageAction['operation'],
29
+ input: unknown,
30
+ ) => Promise<{ data?: { id?: string }; error?: unknown }>;
31
+ };
32
+
33
+ /**
34
+ * Answer a registry-list request with the currently contributed actions as
35
+ * serializable descriptors.
36
+ */
37
+ export const handleListEvent = (detail: unknown, getActions: () => PageAction.PageAction[]): PageAction.ListAck => {
38
+ const decoded = Schema.decodeUnknownResult(PageAction.ListRequest)(detail);
39
+ if (Result.isFailure(decoded)) {
40
+ log.info('rejected invalid page-actions list request');
41
+ // Best-effort id echo so the extension can correlate the failure ack.
42
+ const envelope = Schema.decodeUnknownResult(PageAction.Envelope)(detail);
43
+ const id = Result.isSuccess(envelope) ? (envelope.success.id ?? '') : '';
44
+ return { version: 1, id, ok: false, error: 'invalidPayload' };
45
+ }
46
+ return {
47
+ version: 1,
48
+ id: decoded.success.id,
49
+ ok: true,
50
+ actions: getActions().map(PageAction.toDescriptor),
51
+ };
52
+ };
53
+
54
+ /**
55
+ * Handle a single invoke request. Two-pass decode (loose envelope, then
56
+ * strict payload) so newer versions yield `unsupportedVersion` rather than
57
+ * the generic `invalidPayload`.
58
+ */
59
+ export const handleInvokeEvent = async (detail: unknown, deps: InvokeDeps): Promise<PageAction.InvokeAck> => {
60
+ const envelope = Schema.decodeUnknownResult(PageAction.Envelope)(detail);
61
+ if (Result.isFailure(envelope)) {
62
+ log.info('rejected invalid page-action envelope');
63
+ return { version: 1, id: '', ok: false, error: 'invalidPayload' };
64
+ }
65
+ // Best-effort id echo so the extension can correlate failure acks.
66
+ const envelopeId = envelope.success.id ?? '';
67
+
68
+ // Master toggle: when off, the bridge acks (so the extension does not time
69
+ // out) but ignores all extension-initiated actions.
70
+ if (!Settings.withDefaults(deps.getSettings()).enabled) {
71
+ log.info('ignored page action: extension actions disabled in settings');
72
+ return { version: 1, id: envelopeId, ok: false, error: 'disabled' };
73
+ }
74
+
75
+ if (envelope.success.version !== 1) {
76
+ log.info('rejected unsupported page-action version', { version: envelope.success.version });
77
+ return { version: 1, id: envelopeId, ok: false, error: 'unsupportedVersion' };
78
+ }
79
+
80
+ const decoded = Schema.decodeUnknownResult(PageAction.InvokeRequest)(detail);
81
+ if (Result.isFailure(decoded)) {
82
+ log.info('rejected invalid page-action payload');
83
+ return { version: 1, id: envelopeId, ok: false, error: 'invalidPayload' };
84
+ }
85
+ const request = decoded.success;
86
+
87
+ const action = deps.getActions().find((candidate) => candidate.id === request.actionId);
88
+ if (!action) {
89
+ log.info('rejected unknown page action', { actionId: request.actionId });
90
+ return { version: 1, id: request.id, ok: false, error: 'unknownAction' };
91
+ }
92
+
93
+ const target = deps.getTarget();
94
+ if (!target) {
95
+ return { version: 1, id: request.id, ok: false, error: 'noSpace' };
96
+ }
97
+
98
+ try {
99
+ const { data, error } = await deps.invoke(action.operation, { snapshot: request.inputs, target });
100
+ if (error || !data) {
101
+ log.info('page-action operation failed', { actionId: action.id, error });
102
+ return { version: 1, id: request.id, ok: false, error: 'operationFailed' };
103
+ }
104
+ log.info('page action invoked', { actionId: action.id, objectId: data.id });
105
+ return { version: 1, id: request.id, ok: true, objectId: data.id };
106
+ } catch (err) {
107
+ log.catch(err);
108
+ return { version: 1, id: request.id, ok: false, error: 'operationFailed' };
109
+ }
110
+ };
111
+
112
+ /**
113
+ * Attach the page-actions window event listeners. Returns a disposer.
114
+ * Actions are resolved per-event via `getAll` so late-activating plugins are
115
+ * always included.
116
+ */
117
+ export const installPageActionListeners = (
118
+ capabilities: CapabilityManager.CapabilityManager,
119
+ invoker: Capabilities.OperationInvoker,
120
+ onResult?: (ack: PageAction.InvokeAck, label?: string, spaceId?: SpaceId) => void,
121
+ ): (() => void) => {
122
+ const getActions = () => capabilities.getAll(CrxCapabilities.PageAction).flat();
123
+
124
+ const onList = (event: Event) => {
125
+ const ack = handleListEvent((event as CustomEvent).detail, getActions);
126
+ window.dispatchEvent(new CustomEvent(PageAction.LIST_ACK_EVENT, { detail: ack }));
127
+ };
128
+
129
+ const onInvoke = (event: Event) => {
130
+ const detail = (event as CustomEvent).detail;
131
+ // Resolved once so the ack's object is looked up in the space it was created in.
132
+ let target: Database.Database | undefined;
133
+ const deps: InvokeDeps = {
134
+ getActions,
135
+ getSettings: () => capabilities.get(Capabilities.AtomRegistry).get(capabilities.get(CrxCapabilities.Settings)),
136
+ getTarget: () => {
137
+ const client = capabilities.get(ClientCapabilities.Client);
138
+ target = AppSpace.getActiveSpace(client, capabilities)?.db;
139
+ return target;
140
+ },
141
+ invoke: (operation, input) => invoker.invokePromise(operation, input),
142
+ };
143
+ void handleInvokeEvent(detail, deps)
144
+ .catch((err): PageAction.InvokeAck => {
145
+ log.catch(err);
146
+ return { version: 1, id: '', ok: false, error: 'operationFailed' };
147
+ })
148
+ .then((ack) => {
149
+ window.dispatchEvent(new CustomEvent(PageAction.INVOKE_ACK_EVENT, { detail: ack }));
150
+ try {
151
+ const actionId = (detail as { actionId?: string } | null)?.actionId;
152
+ onResult?.(ack, getActions().find((candidate) => candidate.id === actionId)?.label, target?.spaceId);
153
+ } catch (err) {
154
+ log.catch(err);
155
+ }
156
+ });
157
+ };
158
+
159
+ window.addEventListener(PageAction.LIST_EVENT, onList as EventListener);
160
+ window.addEventListener(PageAction.INVOKE_EVENT, onInvoke as EventListener);
161
+
162
+ // Announce that the page-side listeners are ready so the extension relay
163
+ // can trigger a registry refresh even when Composer boots after the
164
+ // content script has already installed.
165
+ window.dispatchEvent(new CustomEvent(PageAction.READY_EVENT));
166
+
167
+ return () => {
168
+ window.removeEventListener(PageAction.LIST_EVENT, onList as EventListener);
169
+ window.removeEventListener(PageAction.INVOKE_EVENT, onInvoke as EventListener);
170
+ };
171
+ };
package/src/plugin.tsx ADDED
@@ -0,0 +1,29 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Plugin from '@dxos/app-framework/Plugin';
6
+
7
+ import {
8
+ CrxSettings,
9
+ InstallPageActions,
10
+ OperationHandler,
11
+ PageActionProvider,
12
+ PluginAsset,
13
+ ReactSurface,
14
+ Translations,
15
+ } from '#capabilities';
16
+ import { meta } from '#meta';
17
+
18
+ export const CrxPlugin = Plugin.define(meta).pipe(
19
+ Plugin.addModule(CrxSettings),
20
+ Plugin.addModule(InstallPageActions),
21
+ Plugin.addModule(OperationHandler),
22
+ Plugin.addModule(PageActionProvider),
23
+ Plugin.addModule(PluginAsset),
24
+ Plugin.addModule(ReactSurface),
25
+ Plugin.addModule(Translations),
26
+ Plugin.make,
27
+ );
28
+
29
+ export default CrxPlugin;
@@ -0,0 +1,45 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useMemo } from 'react';
7
+
8
+ import { random } from '@dxos/random';
9
+ import { Image, ScrollArea } from '@dxos/react-ui';
10
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
+
12
+ const DefaultStory = () => {
13
+ const { title, image, paragraphs } = useMemo(
14
+ () => ({
15
+ title: random.lorem.sentence(4),
16
+ image: random.image.url(),
17
+ paragraphs: Array.from({ length: 10 }, () => random.lorem.paragraph(3)),
18
+ }),
19
+ [],
20
+ );
21
+
22
+ return (
23
+ <ScrollArea.Root centered padding>
24
+ <ScrollArea.Viewport classNames='flex flex-col gap-2 py-3'>
25
+ <h1 className='text-2xl'>{title}</h1>
26
+ <Image src={image} alt={title} fit='cover' />
27
+ {paragraphs.map((paragraph, index) => (
28
+ <p key={index}>{paragraph}</p>
29
+ ))}
30
+ </ScrollArea.Viewport>
31
+ </ScrollArea.Root>
32
+ );
33
+ };
34
+
35
+ const meta: Meta<typeof DefaultStory> = {
36
+ title: 'plugins/plugin-crx/stories/Testbench',
37
+ component: DefaultStory,
38
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
39
+ };
40
+
41
+ export default meta;
42
+
43
+ type Story = StoryObj<typeof meta>;
44
+
45
+ export const Default: Story = {};
@@ -0,0 +1,31 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Resource } from '@dxos/react-ui';
6
+
7
+ import { meta } from '#meta';
8
+
9
+ export const translations = [
10
+ {
11
+ 'en-US': {
12
+ [meta.profile.key]: {
13
+ 'plugin.name': 'Browser Extension',
14
+ 'settings.description':
15
+ 'Install the browser extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/composer/ahkmmpagcinoehpnmdhoiohmmbikhnmj?pli=1).',
16
+ 'test.title': 'Connection',
17
+ 'test.button.label': 'Test connection',
18
+ 'test.pending.message': 'Contacting extension…',
19
+ 'test.connected.message': 'Connected to {{name}} v{{version}}.',
20
+ 'toast.error.invalidPayload.message': 'Unrecognized payload.',
21
+ 'toast.error.unsupportedVersion.message': 'Unsupported version — update Composer.',
22
+ 'toast.error.disabled.message': 'Extension actions are disabled in settings.',
23
+ 'toast.error.noSpace.message': 'Open a space first.',
24
+ 'toast.page-action.success.title': 'Action complete',
25
+ 'toast.page-action.error.title': 'Action failed',
26
+ 'toast.error.unknownAction.message': 'Unknown action — update Composer or the extension.',
27
+ 'toast.error.operationFailed.message': 'The action failed to run.',
28
+ },
29
+ },
30
+ },
31
+ ] as const satisfies Resource[];
@@ -0,0 +1,26 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import type * as Atom from 'effect/unstable/reactivity/Atom';
8
+
9
+ import * as Capability from '@dxos/app-framework/Capability';
10
+
11
+ import { meta } from '#meta';
12
+
13
+ /**
14
+ * Writable atom holding the plugin's Settings.
15
+ */
16
+ export const Settings = Capability.makeSingleton<Atom.Writable<import('./Settings.ts').Settings>>()(
17
+ `${meta.profile.key}.capability.settings`,
18
+ );
19
+
20
+ /**
21
+ * Page actions contributed by plugins for the browser extension to surface.
22
+ * Multi: each contributor provides one array (e.g. plugin-bookmarks); consumers flatten via `getAll`.
23
+ */
24
+ export const PageAction = Capability.make<import('./PageAction.ts').PageAction[]>()(
25
+ `${meta.profile.key}.capability.pageAction`,
26
+ );
@@ -0,0 +1,16 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import * as ActivationEvent from '@dxos/app-framework/ActivationEvent';
8
+
9
+ import { meta } from '#meta';
10
+
11
+ /**
12
+ * The feature's start event: the plugin's start-gated modules (and cross-plugin
13
+ * contributions consumed by this feature) activate here. Fired on demand and by the
14
+ * host's idle trickle.
15
+ */
16
+ export const Start = ActivationEvent.pluginStart(meta.profile.key);
@@ -0,0 +1,54 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import * as Schema from 'effect/Schema';
8
+
9
+ import * as Operation from '@dxos/compute/Operation';
10
+ import { Database, DXN } from '@dxos/echo';
11
+
12
+ import * as PageAction from './PageAction.ts';
13
+
14
+ const input = Schema.Struct({
15
+ snapshot: PageAction.Snapshot,
16
+ target: Database.Database.annotate({ description: 'The database to add the object to.' }),
17
+ });
18
+
19
+ const output = Schema.Struct({
20
+ id: Schema.String,
21
+ });
22
+
23
+ export const AddPersonFromSnapshot = Operation.make({
24
+ meta: {
25
+ key: DXN.make('org.dxos.operation.crx.addPersonFromSnapshot'),
26
+ name: 'Add person',
27
+ description: 'Create a person from a page snapshot.',
28
+ icon: 'ph--user--regular',
29
+ },
30
+ input,
31
+ output,
32
+ });
33
+
34
+ export const AddOrganizationFromSnapshot = Operation.make({
35
+ meta: {
36
+ key: DXN.make('org.dxos.operation.crx.addOrganizationFromSnapshot'),
37
+ name: 'Add organization',
38
+ description: 'Create an organization from a page snapshot.',
39
+ icon: 'ph--building-office--regular',
40
+ },
41
+ input,
42
+ output,
43
+ });
44
+
45
+ export const AddNoteFromSnapshot = Operation.make({
46
+ meta: {
47
+ key: DXN.make('org.dxos.operation.crx.addNoteFromSnapshot'),
48
+ name: 'Add note',
49
+ description: 'Create a markdown note from a page snapshot.',
50
+ icon: 'ph--note--regular',
51
+ },
52
+ input,
53
+ output,
54
+ });
@@ -0,0 +1,57 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import type * as Operation from '@dxos/compute/Operation';
8
+ import { PageAction as Protocol } from '@dxos/crx-protocol';
9
+
10
+ // Re-export the serializable wire schemas from the shared protocol package.
11
+ export const {
12
+ Rect,
13
+ Source,
14
+ Selection,
15
+ Hints,
16
+ Snapshot,
17
+ Predicate,
18
+ ExtractorRef,
19
+ Context,
20
+ Descriptor,
21
+ PageInfo,
22
+ Envelope,
23
+ ListRequest,
24
+ ListAck,
25
+ InvokeRequest,
26
+ InvokeAck,
27
+ READY_EVENT,
28
+ LIST_EVENT,
29
+ LIST_ACK_EVENT,
30
+ INVOKE_EVENT,
31
+ INVOKE_ACK_EVENT,
32
+ } = Protocol;
33
+
34
+ export type Source = Protocol.Source;
35
+ export type Selection = Protocol.Selection;
36
+ export type Hints = Protocol.Hints;
37
+ export type Snapshot = Protocol.Snapshot;
38
+ export type Predicate = Protocol.Predicate;
39
+ export type ExtractorRef = Protocol.ExtractorRef;
40
+ export type Context = Protocol.Context;
41
+ export type Descriptor = Protocol.Descriptor;
42
+ export type PageInfo = Protocol.PageInfo;
43
+ export type Envelope = Protocol.Envelope;
44
+ export type ListRequest = Protocol.ListRequest;
45
+ export type ListAck = Protocol.ListAck;
46
+ export type InvokeRequest = Protocol.InvokeRequest;
47
+ export type InvokeAck = Protocol.InvokeAck;
48
+
49
+ /** A live page-action contribution: the target operation accepts `{ snapshot, target }` and returns `{ id }`. */
50
+ export type PageAction = Omit<Descriptor, 'operation'> & {
51
+ operation: Operation.Definition.Any;
52
+ };
53
+
54
+ export const toDescriptor = (action: PageAction): Descriptor => ({
55
+ ...action,
56
+ operation: action.operation.meta.key.toString(),
57
+ });
@@ -0,0 +1,30 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import * as Settings from './Settings.ts';
8
+
9
+ describe('Settings', () => {
10
+ test('applies documented defaults when fields are absent', ({ expect }) => {
11
+ expect(Settings.withDefaults({})).toEqual(Settings.defaults);
12
+ expect(Settings.defaults.enabled).toBe(true);
13
+ expect(Settings.defaults.autoOpenAfterClip).toBe(false);
14
+ expect(Settings.defaults.renderProxyEnabled).toBe(true);
15
+ expect(Settings.defaults.renderTimeout).toBe(20_000);
16
+ expect(Settings.defaults.renderActiveTab).toBe(false);
17
+ expect(Settings.defaults.developerMode).toBe(false);
18
+ });
19
+
20
+ test('merges user-set values over defaults', ({ expect }) => {
21
+ const merged = Settings.withDefaults({ renderProxyEnabled: false, renderTimeout: 5_000, enabled: false });
22
+ expect(merged.enabled).toBe(false);
23
+ expect(merged.autoOpenAfterClip).toBe(false);
24
+ expect(merged.renderProxyEnabled).toBe(false);
25
+ expect(merged.renderTimeout).toBe(5_000);
26
+ // Untouched fields fall back to defaults.
27
+ expect(merged.renderActiveTab).toBe(false);
28
+ expect(merged.developerMode).toBe(false);
29
+ });
30
+ });
@@ -0,0 +1,101 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import * as Schema from 'effect/Schema';
8
+ import * as Struct from 'effect/Struct';
9
+
10
+ /**
11
+ * User preferences that govern how Composer coordinates with the composer-crx
12
+ * browser extension — both the page-actions bridge and the render-proxy. All fields
13
+ * are optional; defaults are applied by consumers when reading the atom.
14
+ */
15
+ export const Settings = Schema.Struct({
16
+ /**
17
+ * Master toggle. When false, the bridge plugin ignores incoming extension actions.
18
+ * Defaults to `true`.
19
+ */
20
+ enabled: Schema.optional(
21
+ Schema.Boolean.annotate({
22
+ title: 'Accept extension actions',
23
+ description: 'When off, actions sent from the composer-crx browser extension are ignored.',
24
+ }),
25
+ ),
26
+
27
+ /**
28
+ * Navigate to the created object after the extension creates one. Defaults to
29
+ * `false` to avoid yanking focus during active work.
30
+ */
31
+ autoOpenAfterClip: Schema.optional(
32
+ Schema.Boolean.annotate({
33
+ title: 'Open after action',
34
+ description: 'Navigate to the created object when the extension creates one.',
35
+ }),
36
+ ),
37
+
38
+ /**
39
+ * Master toggle. When false, plugins fall back to the edge HTTP proxy instead of asking the
40
+ * extension to render pages. Defaults to `true`.
41
+ */
42
+ renderProxyEnabled: Schema.optional(
43
+ Schema.Boolean.annotate({
44
+ title: 'Use extension to render pages',
45
+ description: 'Let plugins render pages via the browser extension to scrape client-rendered sites.',
46
+ }),
47
+ ),
48
+
49
+ /**
50
+ * Maximum time (ms) to wait for a page to render before aborting. Defaults to `20000`.
51
+ */
52
+ renderTimeout: Schema.optional(
53
+ Schema.Number.annotate({
54
+ title: 'Render timeout (ms)',
55
+ description: 'Maximum time to wait for a page to finish rendering before giving up.',
56
+ }),
57
+ ),
58
+
59
+ /**
60
+ * Render pages in a focused (foreground) tab. Helps sites that defer loading when backgrounded,
61
+ * at the cost of stealing focus. Defaults to `false`.
62
+ */
63
+ renderActiveTab: Schema.optional(
64
+ Schema.Boolean.annotate({
65
+ title: 'Render in focused tab',
66
+ description: 'Render pages in a focused tab; helps sites that defer loading when backgrounded.',
67
+ }),
68
+ ),
69
+
70
+ /**
71
+ * Enable verbose logging and debug previews in the extension. Defaults to `false`.
72
+ */
73
+ developerMode: Schema.optional(
74
+ Schema.Boolean.annotate({
75
+ title: 'Developer mode',
76
+ description: 'Enable verbose logging and debug previews in the browser extension.',
77
+ }),
78
+ ),
79
+ }).mapFields(Struct.map(Schema.mutableKey));
80
+
81
+ export interface Settings extends Schema.Schema.Type<typeof Settings> {}
82
+
83
+ /**
84
+ * Default values applied when a field is absent. Kept separate from the
85
+ * schema so `createKvsStore` can store only user-set values while consumers
86
+ * (this plugin's components, and any downstream reader) see sensible
87
+ * defaults.
88
+ */
89
+ export const defaults: Required<Settings> = {
90
+ enabled: true,
91
+ autoOpenAfterClip: false,
92
+ renderProxyEnabled: true,
93
+ renderTimeout: 20_000,
94
+ renderActiveTab: false,
95
+ developerMode: false,
96
+ };
97
+
98
+ export const withDefaults = (settings: Settings): Required<Settings> => ({
99
+ ...defaults,
100
+ ...settings,
101
+ });
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * as CrxCapabilities from './CrxCapabilities.ts';
6
+ export * as CrxEvents from './CrxEvents.ts';
7
+ export * as CrxOperation from './CrxOperation.ts';
8
+ export * as PageAction from './PageAction.ts';
9
+ export * as Settings from './Settings.ts';
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './pingExtension.ts';
@@ -0,0 +1,69 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+ // @vitest-environment jsdom
5
+
6
+ import { afterEach, beforeEach, describe, test } from 'vitest';
7
+
8
+ import { Proxy } from '@dxos/crx-protocol';
9
+
10
+ import { isExtensionAvailable, pingExtension } from './pingExtension.ts';
11
+
12
+ const setAvailable = (available: boolean) => {
13
+ if (available) {
14
+ document.documentElement.dataset[Proxy.RENDER_READY_DATASET_KEY] = '1';
15
+ } else {
16
+ delete document.documentElement.dataset[Proxy.RENDER_READY_DATASET_KEY];
17
+ }
18
+ };
19
+
20
+ // Stand in for the extension content relay: ack each ping with the given outcome.
21
+ const installFakeRelay = (respond: (id: string) => unknown) => {
22
+ const listener = (event: Event) => {
23
+ const detail = (event as CustomEvent).detail as { id: string };
24
+ window.dispatchEvent(new CustomEvent(Proxy.PING_ACK_EVENT, { detail: respond(detail.id) }));
25
+ };
26
+ window.addEventListener(Proxy.PING_EVENT, listener);
27
+ return () => window.removeEventListener(Proxy.PING_EVENT, listener);
28
+ };
29
+
30
+ describe('pingExtension', () => {
31
+ beforeEach(() => setAvailable(false));
32
+ afterEach(() => setAvailable(false));
33
+
34
+ test('isExtensionAvailable reflects the dataset marker', ({ expect }) => {
35
+ expect(isExtensionAvailable()).toBe(false);
36
+ setAvailable(true);
37
+ expect(isExtensionAvailable()).toBe(true);
38
+ });
39
+
40
+ test('resolves with the extension identity on a successful ack', async ({ expect }) => {
41
+ setAvailable(true);
42
+ const uninstall = installFakeRelay((id) => ({
43
+ version: 1,
44
+ id,
45
+ ok: true,
46
+ extensionVersion: '0.8.3',
47
+ extensionName: 'Composer',
48
+ }));
49
+ await expect(pingExtension()).resolves.toEqual({ extensionVersion: '0.8.3', extensionName: 'Composer' });
50
+ uninstall();
51
+ });
52
+
53
+ test('rejects when the extension is not detected', async ({ expect }) => {
54
+ setAvailable(false);
55
+ await expect(pingExtension()).rejects.toThrow(/not detected/);
56
+ });
57
+
58
+ test('rejects on a non-ok ack', async ({ expect }) => {
59
+ setAvailable(true);
60
+ const uninstall = installFakeRelay((id) => ({ version: 1, id, ok: false, error: 'forbiddenOrigin' }));
61
+ await expect(pingExtension()).rejects.toThrow(/forbiddenOrigin/);
62
+ uninstall();
63
+ });
64
+
65
+ test('rejects on timeout when no ack arrives', async ({ expect }) => {
66
+ setAvailable(true);
67
+ await expect(pingExtension(50)).rejects.toThrow(/did not respond/);
68
+ });
69
+ });