@odyn-app/api 0.0.0-stage → 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/README.md +24 -2
- package/dist/index.d.ts +492 -0
- package/dist/index.js +487 -0
- package/dist/ui/button.d.ts +10 -0
- package/dist/ui/button.js +31 -0
- package/dist/ui/cn.d.ts +3 -0
- package/dist/ui/cn.js +6 -0
- package/dist/ui/index.d.ts +10 -0
- package/dist/ui/index.js +10 -0
- package/dist/ui/orb/orb-base.module.css +21 -0
- package/dist/ui/orb/orb-geometry-cache.d.ts +1 -0
- package/dist/ui/orb/orb-geometry-cache.js +11 -0
- package/dist/ui/orb/orb-helix-geometry.d.ts +7 -0
- package/dist/ui/orb/orb-helix-geometry.js +130 -0
- package/dist/ui/orb/orb-helix.module.css +155 -0
- package/dist/ui/orb/orb-lattice-geometry.d.ts +35 -0
- package/dist/ui/orb/orb-lattice-geometry.js +148 -0
- package/dist/ui/orb/orb-lattice.module.css +165 -0
- package/dist/ui/orb/orb-lens-primary.module.css +195 -0
- package/dist/ui/orb/orb-lens-secondary.module.css +143 -0
- package/dist/ui/orb/orb-lens.module.css +44 -0
- package/dist/ui/orb/orb-morph-geometry.d.ts +7 -0
- package/dist/ui/orb/orb-morph-geometry.js +88 -0
- package/dist/ui/orb/orb-morph.module.css +62 -0
- package/dist/ui/orb/orb-ring-geometry.d.ts +8 -0
- package/dist/ui/orb/orb-ring-geometry.js +42 -0
- package/dist/ui/orb/orb-ring.module.css +113 -0
- package/dist/ui/orb/orb-types.d.ts +23 -0
- package/dist/ui/orb/orb-types.js +48 -0
- package/dist/ui/orb/orb.d.ts +17 -0
- package/dist/ui/orb/orb.js +40 -0
- package/dist/ui/setting-row.d.ts +9 -0
- package/dist/ui/setting-row.js +9 -0
- package/dist/ui/skeleton.d.ts +3 -0
- package/dist/ui/skeleton.js +6 -0
- package/dist/ui/theme.css +232 -0
- package/package.json +56 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// The public building kit for Odyn extensions.
|
|
2
|
+
//
|
|
3
|
+
// This is the ONLY part of Odyn an extension author sees. It describes what
|
|
4
|
+
// an extension may ask for (its manifest), what it may draw (plain data —
|
|
5
|
+
// lists, details, forms — or, from version 2, its own small page shown in a
|
|
6
|
+
// sealed box), and what it may call (a short list of actions, each behind a
|
|
7
|
+
// permission the user agreed to at install). From version 3 an official
|
|
8
|
+
// extension may also run a background helper that drives Mac programs, show
|
|
9
|
+
// live screens in the sidebar, and give the agent a guide. From version 4
|
|
10
|
+
// any extension may hand the agent tools that take inputs, and an extension
|
|
11
|
+
// may be nothing more than a guide or tools for the agent.
|
|
12
|
+
// Keep it dependency-free: it is shared by Odyn, the store checker and every
|
|
13
|
+
// extension.
|
|
14
|
+
/** The newest building-kit version. Bumped when something new is added. */
|
|
15
|
+
export const EXTENSION_API_VERSION = 4;
|
|
16
|
+
/** The oldest building-kit version Odyn still runs. */
|
|
17
|
+
export const EXTENSION_API_MIN_VERSION = 1;
|
|
18
|
+
// --- Manifest -------------------------------------------------------------
|
|
19
|
+
/** Everything an extension can ask the user to allow. */
|
|
20
|
+
export const EXTENSION_PERMISSIONS = [
|
|
21
|
+
'chats:read',
|
|
22
|
+
'projects:read',
|
|
23
|
+
'agents:start',
|
|
24
|
+
'network',
|
|
25
|
+
/** Put a picture onto the message the user is writing. */
|
|
26
|
+
'composer:attach',
|
|
27
|
+
/** Receive a recent Mac screenshot when the user hands one over. */
|
|
28
|
+
'screenshots:read',
|
|
29
|
+
/** Run the Mac programs listed in `helper.programs`. Official extensions only. */
|
|
30
|
+
'programs:run',
|
|
31
|
+
/** Give the agent a guide, and ask before it uses the listed commands. */
|
|
32
|
+
'agent:tools',
|
|
33
|
+
];
|
|
34
|
+
/** Permissions only extensions made by Odyn may ask for. */
|
|
35
|
+
export const OFFICIAL_ONLY_PERMISSIONS = ['programs:run'];
|
|
36
|
+
/** Systems an extension can say it runs on. Leave out to run everywhere. */
|
|
37
|
+
export const EXTENSION_PLATFORMS = ['darwin', 'linux', 'win32'];
|
|
38
|
+
/** Where an extension can show up inside Odyn. */
|
|
39
|
+
export const EXTENSION_PLACEMENTS = ['sidebarPanel', 'chatCard', 'agentTool', 'settings'];
|
|
40
|
+
/** The kinds of value an agent tool's input can take. */
|
|
41
|
+
export const EXTENSION_INPUT_TYPES = ['string', 'number', 'boolean'];
|
|
42
|
+
/** Where an extension's own page can be shown. */
|
|
43
|
+
export const EXTENSION_SURFACE_PLACEMENTS = ['composer', 'settings'];
|
|
44
|
+
/** The icons Odyn can draw on the button that opens a page. */
|
|
45
|
+
export const EXTENSION_SURFACE_ICONS = ['puzzle', 'pencil', 'image', 'note'];
|
|
46
|
+
export const SURFACE_MIN_HEIGHT = 120;
|
|
47
|
+
export const SURFACE_MAX_HEIGHT = 600;
|
|
48
|
+
/** Every method name the bridge accepts, so Odyn can refuse anything else. */
|
|
49
|
+
export const EXTENSION_API_METHODS = [
|
|
50
|
+
'chats.list',
|
|
51
|
+
'chats.messages',
|
|
52
|
+
'chats.current',
|
|
53
|
+
'projects.list',
|
|
54
|
+
'agents.start',
|
|
55
|
+
'fetch',
|
|
56
|
+
'showToast',
|
|
57
|
+
'preferences',
|
|
58
|
+
];
|
|
59
|
+
function buildOdynApi(bridge) {
|
|
60
|
+
const call = (method, ...args) => bridge.call(method, args);
|
|
61
|
+
return {
|
|
62
|
+
chats: {
|
|
63
|
+
list: (projectId) => call('chats.list', projectId ?? null),
|
|
64
|
+
messages: (chatId, limit) => call('chats.messages', chatId, limit ?? null),
|
|
65
|
+
current: () => call('chats.current'),
|
|
66
|
+
},
|
|
67
|
+
projects: { list: () => call('projects.list') },
|
|
68
|
+
agents: { start: (chatId, prompt) => call('agents.start', chatId, prompt) },
|
|
69
|
+
fetch: (url, init) => call('fetch', url, init ?? null),
|
|
70
|
+
showToast: (message) => call('showToast', message),
|
|
71
|
+
preferences: () => call('preferences'),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function errorText(error) {
|
|
75
|
+
return error instanceof Error ? error.message : String(error);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The one call an extension's entry file makes. Inside Odyn it hooks the
|
|
79
|
+
* commands up to Odyn; anywhere else (tests, the store checker) it just
|
|
80
|
+
* hands the definition back.
|
|
81
|
+
*/
|
|
82
|
+
export function defineExtension(definition) {
|
|
83
|
+
const bridge = globalThis.__odynBridge;
|
|
84
|
+
if (!bridge)
|
|
85
|
+
return definition;
|
|
86
|
+
const odyn = buildOdynApi(bridge);
|
|
87
|
+
bridge.onRun(({ callId, command, ...rest }) => {
|
|
88
|
+
const handler = definition.commands[command];
|
|
89
|
+
if (!handler) {
|
|
90
|
+
bridge.finish(callId, { ok: false, error: `This extension has no command called "${command}".` });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
handler({ odyn, ...rest })
|
|
94
|
+
.then((value) => bridge.finish(callId, { ok: true, value: value ?? null }))
|
|
95
|
+
.catch((error) => bridge.finish(callId, { ok: false, error: errorText(error) }));
|
|
96
|
+
});
|
|
97
|
+
return definition;
|
|
98
|
+
}
|
|
99
|
+
// --- Checking a manifest --------------------------------------------------
|
|
100
|
+
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
101
|
+
const ENTRY_PATTERN = /^(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.html$/;
|
|
102
|
+
function surfaceProblems(surfaces, apiVersion) {
|
|
103
|
+
if (surfaces.length === 0)
|
|
104
|
+
return [];
|
|
105
|
+
const problems = [];
|
|
106
|
+
if (apiVersion < 2)
|
|
107
|
+
problems.push('Pages (`surfaces`) need `apiVersion` 2.');
|
|
108
|
+
const ids = new Set();
|
|
109
|
+
for (const surface of surfaces) {
|
|
110
|
+
const label = `Page "${String(surface?.id)}"`;
|
|
111
|
+
if (!ID_PATTERN.test(surface?.id ?? ''))
|
|
112
|
+
problems.push(`${label} has an invalid id.`);
|
|
113
|
+
if (ids.has(surface.id))
|
|
114
|
+
problems.push(`${label} is listed twice.`);
|
|
115
|
+
ids.add(surface.id);
|
|
116
|
+
if (typeof surface.title !== 'string' || surface.title.trim() === '')
|
|
117
|
+
problems.push(`${label} needs a title.`);
|
|
118
|
+
if (!EXTENSION_SURFACE_PLACEMENTS.includes(surface.placement))
|
|
119
|
+
problems.push(`${label} has an unknown placement.`);
|
|
120
|
+
if (typeof surface.entry !== 'string' || !ENTRY_PATTERN.test(surface.entry))
|
|
121
|
+
problems.push(`${label} needs an \`entry\` like "dist/panel/index.html".`);
|
|
122
|
+
if (surface.icon !== undefined && !EXTENSION_SURFACE_ICONS.includes(surface.icon))
|
|
123
|
+
problems.push(`${label} has an unknown icon.`);
|
|
124
|
+
if (surface.height !== undefined && (typeof surface.height !== 'number' || surface.height < SURFACE_MIN_HEIGHT || surface.height > SURFACE_MAX_HEIGHT)) {
|
|
125
|
+
problems.push(`${label} height must be between ${SURFACE_MIN_HEIGHT} and ${SURFACE_MAX_HEIGHT}.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (surfaces.filter((surface) => surface.placement === 'composer').length > 1)
|
|
129
|
+
problems.push('Only one page can sit in the message box.');
|
|
130
|
+
if (surfaces.filter((surface) => surface.placement === 'settings').length > 1)
|
|
131
|
+
problems.push('Only one page can sit in the settings window.');
|
|
132
|
+
if (apiVersion < 3 && surfaces.some((surface) => surface.placement === 'settings'))
|
|
133
|
+
problems.push('A settings page needs `apiVersion` 3.');
|
|
134
|
+
return problems;
|
|
135
|
+
}
|
|
136
|
+
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
137
|
+
const SCRIPT_PATTERN = /^(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.js$/;
|
|
138
|
+
const FOLDER_PATTERN = /^(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/;
|
|
139
|
+
function locationProblems(label, locations) {
|
|
140
|
+
if (!Array.isArray(locations) || locations.length === 0)
|
|
141
|
+
return [`${label} needs at least one location.`];
|
|
142
|
+
const bad = locations.filter((location) => typeof location !== 'string' || location.trim() === '' || location.includes('..'));
|
|
143
|
+
return bad.length > 0 ? [`${label} has a location Odyn cannot use.`] : [];
|
|
144
|
+
}
|
|
145
|
+
function helperProblems(manifest, apiVersion) {
|
|
146
|
+
const helper = manifest.helper;
|
|
147
|
+
const asksPrograms = Array.isArray(manifest.permissions) && manifest.permissions.includes('programs:run');
|
|
148
|
+
if (helper === undefined)
|
|
149
|
+
return asksPrograms ? ['`programs:run` needs a `helper`.'] : [];
|
|
150
|
+
const problems = [];
|
|
151
|
+
if (apiVersion < 3)
|
|
152
|
+
problems.push('A `helper` needs `apiVersion` 3.');
|
|
153
|
+
if (!asksPrograms)
|
|
154
|
+
problems.push('A `helper` needs the `programs:run` permission.');
|
|
155
|
+
if (typeof helper !== 'object' || helper === null)
|
|
156
|
+
return [...problems, '`helper` must be an object.'];
|
|
157
|
+
if (typeof helper.entry !== 'string' || !SCRIPT_PATTERN.test(helper.entry))
|
|
158
|
+
problems.push('`helper.entry` must be a script like "dist/helper.js".');
|
|
159
|
+
const named = (list, kind) => {
|
|
160
|
+
if (list === undefined && kind === 'folder')
|
|
161
|
+
return;
|
|
162
|
+
if (!Array.isArray(list)) {
|
|
163
|
+
problems.push(`\`helper.${kind}s\` must be a list.`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const names = new Set();
|
|
167
|
+
for (const item of list) {
|
|
168
|
+
const label = `Helper ${kind} "${String(item?.name)}"`;
|
|
169
|
+
if (typeof item?.name !== 'string' || !NAME_PATTERN.test(item.name))
|
|
170
|
+
problems.push(`${label} has an invalid name.`);
|
|
171
|
+
else if (names.has(item.name))
|
|
172
|
+
problems.push(`${label} is listed twice.`);
|
|
173
|
+
else
|
|
174
|
+
names.add(item.name);
|
|
175
|
+
problems.push(...locationProblems(label, item?.locations));
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
named(helper.programs, 'program');
|
|
179
|
+
named(helper.folders, 'folder');
|
|
180
|
+
if (helper.links !== undefined && (!Array.isArray(helper.links) || helper.links.some((link) => typeof link !== 'string' || !/^[a-z][a-z0-9+.-]*:\/\/\S+$/.test(link)))) {
|
|
181
|
+
problems.push('`helper.links` must be a list of full links.');
|
|
182
|
+
}
|
|
183
|
+
if (helper.downloadHosts !== undefined && (!Array.isArray(helper.downloadHosts) || helper.downloadHosts.some((host) => typeof host !== 'string' || !/^[a-z0-9.-]+$/.test(host)))) {
|
|
184
|
+
problems.push('`helper.downloadHosts` must be a list of host names.');
|
|
185
|
+
}
|
|
186
|
+
return problems;
|
|
187
|
+
}
|
|
188
|
+
function agentProblems(manifest, apiVersion) {
|
|
189
|
+
const agent = manifest.agent;
|
|
190
|
+
const asksAgent = Array.isArray(manifest.permissions) && manifest.permissions.includes('agent:tools');
|
|
191
|
+
if (agent === undefined)
|
|
192
|
+
return asksAgent ? ['`agent:tools` needs an `agent` section.'] : [];
|
|
193
|
+
const problems = [];
|
|
194
|
+
if (apiVersion < 3)
|
|
195
|
+
problems.push('An `agent` section needs `apiVersion` 3.');
|
|
196
|
+
if (!asksAgent)
|
|
197
|
+
problems.push('An `agent` section needs the `agent:tools` permission.');
|
|
198
|
+
if (typeof agent !== 'object' || agent === null)
|
|
199
|
+
return [...problems, '`agent` must be an object.'];
|
|
200
|
+
// From version 4 the guide is optional, but it comes as a pair.
|
|
201
|
+
const guideOptional = apiVersion >= 4 && agent.skill === undefined && agent.skillFolder === undefined;
|
|
202
|
+
if (!guideOptional) {
|
|
203
|
+
if (typeof agent.skill !== 'string' || !ID_PATTERN.test(agent.skill))
|
|
204
|
+
problems.push('`agent.skill` must be lowercase words joined by dashes.');
|
|
205
|
+
if (typeof agent.skillFolder !== 'string' || !FOLDER_PATTERN.test(agent.skillFolder))
|
|
206
|
+
problems.push('`agent.skillFolder` must be a folder inside the extension.');
|
|
207
|
+
}
|
|
208
|
+
if (typeof agent.summary !== 'string' || agent.summary.trim() === '')
|
|
209
|
+
problems.push('`agent.summary` is required.');
|
|
210
|
+
if (agent.gatedCommands !== undefined) {
|
|
211
|
+
if (!Array.isArray(agent.gatedCommands))
|
|
212
|
+
problems.push('`agent.gatedCommands` must be a list.');
|
|
213
|
+
else if (agent.gatedCommands.some((command) => typeof command?.program !== 'string' || !NAME_PATTERN.test(command.program) || (command.subcommand !== undefined && typeof command.subcommand !== 'string'))) {
|
|
214
|
+
problems.push('Every gated command needs a `program` name.');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return problems;
|
|
218
|
+
}
|
|
219
|
+
const INPUT_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
|
|
220
|
+
function inputProblems(command, apiVersion) {
|
|
221
|
+
if (command.inputs === undefined)
|
|
222
|
+
return [];
|
|
223
|
+
const label = `Agent tool "${command.name}"`;
|
|
224
|
+
if (command.placement !== 'agentTool')
|
|
225
|
+
return [`Command "${command.name}" has inputs, but only agent tools take them.`];
|
|
226
|
+
if (apiVersion < 4)
|
|
227
|
+
return [`${label} inputs need \`apiVersion\` 4.`];
|
|
228
|
+
if (!Array.isArray(command.inputs))
|
|
229
|
+
return [`${label} inputs must be a list.`];
|
|
230
|
+
const problems = [];
|
|
231
|
+
const names = new Set();
|
|
232
|
+
for (const input of command.inputs) {
|
|
233
|
+
const name = typeof input?.name === 'string' ? input.name : '';
|
|
234
|
+
if (!INPUT_NAME_PATTERN.test(name))
|
|
235
|
+
problems.push(`${label} has an input with an invalid name.`);
|
|
236
|
+
else if (names.has(name))
|
|
237
|
+
problems.push(`${label} lists the input "${name}" twice.`);
|
|
238
|
+
names.add(name);
|
|
239
|
+
if (typeof input?.description !== 'string' || input.description.trim() === '')
|
|
240
|
+
problems.push(`${label} input "${name}" needs a description.`);
|
|
241
|
+
if (!EXTENSION_INPUT_TYPES.includes(input?.type))
|
|
242
|
+
problems.push(`${label} input "${name}" must be one of: ${EXTENSION_INPUT_TYPES.join(', ')}.`);
|
|
243
|
+
if (input?.required !== undefined && typeof input.required !== 'boolean')
|
|
244
|
+
problems.push(`${label} input "${name}" has a \`required\` that is not true or false.`);
|
|
245
|
+
}
|
|
246
|
+
return problems;
|
|
247
|
+
}
|
|
248
|
+
export function validateManifest(value, options = {}) {
|
|
249
|
+
if (typeof value !== 'object' || value === null)
|
|
250
|
+
return ['The manifest is not an object.'];
|
|
251
|
+
const manifest = value;
|
|
252
|
+
const problems = [];
|
|
253
|
+
if (typeof manifest.id !== 'string' || !ID_PATTERN.test(manifest.id))
|
|
254
|
+
problems.push('`id` must be lowercase words joined by dashes.');
|
|
255
|
+
for (const key of ['title', 'description', 'author', 'version']) {
|
|
256
|
+
if (typeof manifest[key] !== 'string' || manifest[key].trim() === '')
|
|
257
|
+
problems.push(`\`${key}\` is required.`);
|
|
258
|
+
}
|
|
259
|
+
const apiVersion = typeof manifest.apiVersion === 'number' ? manifest.apiVersion : 0;
|
|
260
|
+
if (!Number.isInteger(apiVersion) || apiVersion < EXTENSION_API_MIN_VERSION || apiVersion > EXTENSION_API_VERSION) {
|
|
261
|
+
problems.push(`\`apiVersion\` must be between ${EXTENSION_API_MIN_VERSION} and ${EXTENSION_API_VERSION}.`);
|
|
262
|
+
}
|
|
263
|
+
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions : null;
|
|
264
|
+
if (!permissions)
|
|
265
|
+
problems.push('`permissions` must be a list.');
|
|
266
|
+
else {
|
|
267
|
+
const unknown = permissions.filter((permission) => !EXTENSION_PERMISSIONS.includes(permission));
|
|
268
|
+
if (unknown.length > 0)
|
|
269
|
+
problems.push(`Unknown permissions: ${unknown.join(', ')}.`);
|
|
270
|
+
if (permissions.includes('network') && !(manifest.allowedHosts?.length))
|
|
271
|
+
problems.push('`network` needs at least one entry in `allowedHosts`.');
|
|
272
|
+
}
|
|
273
|
+
const surfaces = manifest.surfaces ?? [];
|
|
274
|
+
if (!Array.isArray(surfaces))
|
|
275
|
+
problems.push('`surfaces` must be a list.');
|
|
276
|
+
else
|
|
277
|
+
problems.push(...surfaceProblems(surfaces, apiVersion));
|
|
278
|
+
if (manifest.platforms !== undefined) {
|
|
279
|
+
if (apiVersion < 3)
|
|
280
|
+
problems.push('`platforms` needs `apiVersion` 3.');
|
|
281
|
+
if (!Array.isArray(manifest.platforms) || manifest.platforms.length === 0 || manifest.platforms.some((platform) => !EXTENSION_PLATFORMS.includes(platform))) {
|
|
282
|
+
problems.push(`\`platforms\` must list some of: ${EXTENSION_PLATFORMS.join(', ')}.`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
problems.push(...helperProblems(manifest, apiVersion), ...agentProblems(manifest, apiVersion));
|
|
286
|
+
const hasSurfaces = Array.isArray(surfaces) && surfaces.length > 0;
|
|
287
|
+
const hasHelper = manifest.helper !== undefined;
|
|
288
|
+
// From version 4 a guide for the agent is enough on its own.
|
|
289
|
+
const hasAgent = apiVersion >= 4 && manifest.agent !== undefined;
|
|
290
|
+
if (!Array.isArray(manifest.commands))
|
|
291
|
+
problems.push('`commands` must be a list.');
|
|
292
|
+
else if (manifest.commands.length === 0 && !hasSurfaces && !hasHelper && !hasAgent && !options.official)
|
|
293
|
+
problems.push('At least one command, page, helper or agent section is required.');
|
|
294
|
+
else {
|
|
295
|
+
const names = new Set();
|
|
296
|
+
for (const command of manifest.commands) {
|
|
297
|
+
if (!ID_PATTERN.test(command?.name ?? ''))
|
|
298
|
+
problems.push(`Command name "${String(command?.name)}" is invalid.`);
|
|
299
|
+
if (names.has(command.name))
|
|
300
|
+
problems.push(`Command "${command.name}" is listed twice.`);
|
|
301
|
+
names.add(command.name);
|
|
302
|
+
if (!EXTENSION_PLACEMENTS.includes(command.placement))
|
|
303
|
+
problems.push(`Command "${command.name}" has an unknown placement.`);
|
|
304
|
+
if (command.placement === 'agentTool' && !command.description)
|
|
305
|
+
problems.push(`Agent tool "${command.name}" needs a description.`);
|
|
306
|
+
problems.push(...inputProblems(command, apiVersion));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return problems;
|
|
310
|
+
}
|
|
311
|
+
// --- An extension's own page (kit version 2) -----------------------------
|
|
312
|
+
//
|
|
313
|
+
// The page runs in a sealed box inside Odyn's window. It talks to Odyn only by
|
|
314
|
+
// posting messages to the box's parent; Odyn checks every call against the
|
|
315
|
+
// permissions the user agreed to before doing anything.
|
|
316
|
+
/** Every message between a page and Odyn carries this tag. */
|
|
317
|
+
export const SURFACE_MESSAGE_TAG = 'odyn-surface';
|
|
318
|
+
/** Every call a page can make, so Odyn can refuse anything else. */
|
|
319
|
+
export const EXTENSION_SURFACE_METHODS = ['chatStorage.get', 'chatStorage.set', 'composer.attachImage', 'helper.call'];
|
|
320
|
+
/** The most a page may keep for one chat, and the largest picture it may attach. */
|
|
321
|
+
export const SURFACE_STORAGE_MAX_BYTES = 5_000_000;
|
|
322
|
+
export const SURFACE_IMAGE_MAX_BYTES = 10_000_000;
|
|
323
|
+
export function isSurfaceMessage(value) {
|
|
324
|
+
return typeof value === 'object' && value !== null && value.tag === SURFACE_MESSAGE_TAG;
|
|
325
|
+
}
|
|
326
|
+
/** The one call a page makes to start talking to Odyn. */
|
|
327
|
+
export function connectSurface() {
|
|
328
|
+
const pending = new Map();
|
|
329
|
+
const contextListeners = new Set();
|
|
330
|
+
const imageListeners = new Set();
|
|
331
|
+
const helperListeners = new Set();
|
|
332
|
+
let latestContext = null;
|
|
333
|
+
let nextCallNumber = 0;
|
|
334
|
+
const post = (message) => window.parent.postMessage(message, '*');
|
|
335
|
+
window.addEventListener('message', (event) => {
|
|
336
|
+
if (event.source !== window.parent || !isSurfaceMessage(event.data))
|
|
337
|
+
return;
|
|
338
|
+
const message = event.data;
|
|
339
|
+
if (message.kind === 'context') {
|
|
340
|
+
latestContext = message.context;
|
|
341
|
+
contextListeners.forEach((listener) => listener(message.context));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (message.kind === 'image') {
|
|
345
|
+
imageListeners.forEach((listener) => listener(message.image));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (message.kind === 'helperMessage') {
|
|
349
|
+
helperListeners.forEach((listener) => listener(message.message));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const call = pending.get(message.id);
|
|
353
|
+
if (!call)
|
|
354
|
+
return;
|
|
355
|
+
pending.delete(message.id);
|
|
356
|
+
if (message.ok)
|
|
357
|
+
call.resolve(message.value ?? null);
|
|
358
|
+
else
|
|
359
|
+
call.reject(new Error(message.error ?? 'Odyn refused the request.'));
|
|
360
|
+
});
|
|
361
|
+
const call = (method, ...args) => {
|
|
362
|
+
nextCallNumber += 1;
|
|
363
|
+
const id = `call-${nextCallNumber}`;
|
|
364
|
+
return new Promise((resolve, reject) => {
|
|
365
|
+
pending.set(id, { resolve: resolve, reject });
|
|
366
|
+
post({ tag: SURFACE_MESSAGE_TAG, kind: 'call', id, method, args });
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
post({ tag: SURFACE_MESSAGE_TAG, kind: 'ready' });
|
|
370
|
+
return {
|
|
371
|
+
onContext: (listener) => {
|
|
372
|
+
contextListeners.add(listener);
|
|
373
|
+
if (latestContext)
|
|
374
|
+
listener(latestContext);
|
|
375
|
+
return () => contextListeners.delete(listener);
|
|
376
|
+
},
|
|
377
|
+
onImage: (listener) => {
|
|
378
|
+
imageListeners.add(listener);
|
|
379
|
+
return () => imageListeners.delete(listener);
|
|
380
|
+
},
|
|
381
|
+
chatStorage: {
|
|
382
|
+
get: (chatId, key) => call('chatStorage.get', chatId, key),
|
|
383
|
+
set: (chatId, key, value) => call('chatStorage.set', chatId, key, value),
|
|
384
|
+
},
|
|
385
|
+
composer: {
|
|
386
|
+
attachImage: (image) => call('composer.attachImage', image),
|
|
387
|
+
close: () => post({ tag: SURFACE_MESSAGE_TAG, kind: 'close' }),
|
|
388
|
+
},
|
|
389
|
+
helper: {
|
|
390
|
+
call: (name, ...args) => call('helper.call', name, args),
|
|
391
|
+
onMessage: (listener) => {
|
|
392
|
+
helperListeners.add(listener);
|
|
393
|
+
return () => helperListeners.delete(listener);
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
// --- The background helper (kit version 3, official only) -----------------
|
|
399
|
+
//
|
|
400
|
+
// The helper runs in its own sealed box, like a command: no files, no
|
|
401
|
+
// internet, no Node. Everything it does goes through Odyn, which checks the
|
|
402
|
+
// manifest first: only listed programs run, only listed folders are looked
|
|
403
|
+
// up, only listed links open, and downloads only reach listed hosts.
|
|
404
|
+
/** Devices Odyn knows how to draw a live screen for. */
|
|
405
|
+
export const SCREEN_SESSION_DEVICES = ['iphone', 'android'];
|
|
406
|
+
/** Every call a helper can make, so Odyn can refuse anything else. */
|
|
407
|
+
export const EXTENSION_HELPER_METHODS = [
|
|
408
|
+
'programs.find',
|
|
409
|
+
'programs.run',
|
|
410
|
+
'programs.launch',
|
|
411
|
+
'folders.find',
|
|
412
|
+
'data.path',
|
|
413
|
+
'data.download',
|
|
414
|
+
'data.remove',
|
|
415
|
+
'data.makeFolder',
|
|
416
|
+
'openLink',
|
|
417
|
+
'showToast',
|
|
418
|
+
'sessions.set',
|
|
419
|
+
'sessions.frame',
|
|
420
|
+
'pages.post',
|
|
421
|
+
];
|
|
422
|
+
/** The events Odyn sends a helper. */
|
|
423
|
+
export const EXTENSION_HELPER_EVENTS = ['watch', 'unwatch', 'input', 'close', 'agentCommand', 'chatClosed', 'shutdown', 'pageCall'];
|
|
424
|
+
/** The one call a helper's script makes. Anywhere but Odyn it just hands the definition back. */
|
|
425
|
+
export function defineHelper(definition) {
|
|
426
|
+
const bridge = globalThis.__odynHelperBridge;
|
|
427
|
+
if (!bridge)
|
|
428
|
+
return definition;
|
|
429
|
+
const call = (method, ...args) => bridge.call(method, args);
|
|
430
|
+
const pageHandlers = new Map();
|
|
431
|
+
const odyn = {
|
|
432
|
+
programs: {
|
|
433
|
+
find: (name) => call('programs.find', name),
|
|
434
|
+
run: (name, args, options) => call('programs.run', name, args, options ?? null),
|
|
435
|
+
launch: (name, args, options) => call('programs.launch', name, args, options ?? null),
|
|
436
|
+
},
|
|
437
|
+
folders: { find: (name) => call('folders.find', name) },
|
|
438
|
+
data: {
|
|
439
|
+
path: () => call('data.path'),
|
|
440
|
+
download: (url, relativePath, integrity) => call('data.download', url, relativePath, integrity ?? null),
|
|
441
|
+
remove: (relativePath) => call('data.remove', relativePath),
|
|
442
|
+
makeFolder: (relativePath) => call('data.makeFolder', relativePath),
|
|
443
|
+
},
|
|
444
|
+
openLink: (url) => call('openLink', url),
|
|
445
|
+
showToast: (message) => call('showToast', message),
|
|
446
|
+
sessions: {
|
|
447
|
+
set: (sessions) => call('sessions.set', sessions),
|
|
448
|
+
frame: (sessionId, bytes, mimeType) => call('sessions.frame', sessionId, bytes, mimeType),
|
|
449
|
+
},
|
|
450
|
+
pages: {
|
|
451
|
+
handle: (name, handler) => {
|
|
452
|
+
pageHandlers.set(name, handler);
|
|
453
|
+
},
|
|
454
|
+
post: (message) => call('pages.post', message),
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
const settle = (replyId, work) => {
|
|
458
|
+
Promise.resolve()
|
|
459
|
+
.then(work)
|
|
460
|
+
.then((value) => {
|
|
461
|
+
if (replyId)
|
|
462
|
+
bridge.reply(replyId, { ok: true, value: value ?? null });
|
|
463
|
+
})
|
|
464
|
+
.catch((error) => {
|
|
465
|
+
if (replyId)
|
|
466
|
+
bridge.reply(replyId, { ok: false, error: errorText(error) });
|
|
467
|
+
else
|
|
468
|
+
console.error('[helper]', error);
|
|
469
|
+
});
|
|
470
|
+
};
|
|
471
|
+
bridge.onEvent((event, args, replyId) => {
|
|
472
|
+
if (event === 'pageCall') {
|
|
473
|
+
const [name, callArgs] = args;
|
|
474
|
+
const handler = typeof name === 'string' ? pageHandlers.get(name) : undefined;
|
|
475
|
+
settle(replyId, () => {
|
|
476
|
+
if (!handler)
|
|
477
|
+
throw new Error(`The helper has nothing called "${String(name)}".`);
|
|
478
|
+
return handler(...(Array.isArray(callArgs) ? callArgs : []));
|
|
479
|
+
});
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const handler = definition[event];
|
|
483
|
+
settle(replyId, () => handler?.call(definition, ...args));
|
|
484
|
+
});
|
|
485
|
+
settle(null, () => definition.start(odyn));
|
|
486
|
+
return definition;
|
|
487
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ComponentProps, ReactElement } from 'react';
|
|
2
|
+
import { type VariantProps } from 'class-variance-authority';
|
|
3
|
+
/** The Odyn button styles, for use on other elements too. */
|
|
4
|
+
export declare const buttonVariants: (props?: ({
|
|
5
|
+
variant?: "default" | "destructive" | "link" | "outline" | "secondary" | "ghost" | null | undefined;
|
|
6
|
+
size?: "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
|
|
7
|
+
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
8
|
+
export type ButtonProps = ComponentProps<'button'> & VariantProps<typeof buttonVariants>;
|
|
9
|
+
/** A button in Odyn's style. Icon-only buttons need an `aria-label`. */
|
|
10
|
+
export declare function Button({ className, variant, size, type, ...props }: ButtonProps): ReactElement;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import { cn } from './cn';
|
|
4
|
+
/** The Odyn button styles, for use on other elements too. */
|
|
5
|
+
export const buttonVariants = cva("inline-flex shrink-0 select-none items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,scale] duration-200 ease-out outline-none active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", {
|
|
6
|
+
variants: {
|
|
7
|
+
variant: {
|
|
8
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
9
|
+
destructive: 'bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60',
|
|
10
|
+
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
|
11
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
12
|
+
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
|
13
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
14
|
+
},
|
|
15
|
+
size: {
|
|
16
|
+
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
|
17
|
+
xs: "h-6 gap-1 px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
18
|
+
sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5',
|
|
19
|
+
lg: 'h-10 px-6 has-[>svg]:px-4',
|
|
20
|
+
icon: 'size-9',
|
|
21
|
+
'icon-xs': "size-6 [&_svg:not([class*='size-'])]:size-3",
|
|
22
|
+
'icon-sm': 'size-8',
|
|
23
|
+
'icon-lg': 'size-10',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: { variant: 'default', size: 'default' },
|
|
27
|
+
});
|
|
28
|
+
/** A button in Odyn's style. Icon-only buttons need an `aria-label`. */
|
|
29
|
+
export function Button({ className, variant = 'default', size = 'default', type = 'button', ...props }) {
|
|
30
|
+
return (_jsx("button", { type: type, "data-slot": "button", "data-variant": variant, "data-size": size, className: cn(buttonVariants({ variant, size, className })), ...props }));
|
|
31
|
+
}
|
package/dist/ui/cn.d.ts
ADDED
package/dist/ui/cn.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ready-made pieces in Odyn's style for extension pages. Pair them with the
|
|
3
|
+
* theme: `@import "@odyn-app/api/theme.css";` in your page's stylesheet.
|
|
4
|
+
*/
|
|
5
|
+
export { Button, buttonVariants, type ButtonProps } from './button';
|
|
6
|
+
export { cn } from './cn';
|
|
7
|
+
export { Orb, type OrbProps, type OrbVariant } from './orb/orb';
|
|
8
|
+
export { ORB_TASKS } from './orb/orb-types';
|
|
9
|
+
export { SettingRow, type SettingRowProps } from './setting-row';
|
|
10
|
+
export { Skeleton } from './skeleton';
|
package/dist/ui/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ready-made pieces in Odyn's style for extension pages. Pair them with the
|
|
3
|
+
* theme: `@import "@odyn-app/api/theme.css";` in your page's stylesheet.
|
|
4
|
+
*/
|
|
5
|
+
export { Button, buttonVariants } from './button';
|
|
6
|
+
export { cn } from './cn';
|
|
7
|
+
export { Orb } from './orb/orb';
|
|
8
|
+
export { ORB_TASKS } from './orb/orb-types';
|
|
9
|
+
export { SettingRow } from './setting-row';
|
|
10
|
+
export { Skeleton } from './skeleton';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/* Shared stage and inherited animation tokens for every orb family. */
|
|
2
|
+
.root {
|
|
3
|
+
--orb-ease-smooth: cubic-bezier(0.22, 1, 0.36, 1);
|
|
4
|
+
--orb-ease-out: cubic-bezier(0.17, 1, 0.32, 1);
|
|
5
|
+
--orb-ease-in-out: cubic-bezier(0.66, 0, 0.34, 1);
|
|
6
|
+
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
vertical-align: middle;
|
|
10
|
+
color: inherit;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.glyph {
|
|
14
|
+
position: relative;
|
|
15
|
+
display: block;
|
|
16
|
+
flex: none;
|
|
17
|
+
width: 20px;
|
|
18
|
+
height: 20px;
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
contain: strict;
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function memoiseByVariant<Variant, Value>(build: (variant: Variant) => Value): (variant: Variant) => Value;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function memoiseByVariant(build) {
|
|
2
|
+
const cache = new Map();
|
|
3
|
+
return (variant) => {
|
|
4
|
+
const cached = cache.get(variant);
|
|
5
|
+
if (cached !== undefined)
|
|
6
|
+
return cached;
|
|
7
|
+
const built = build(variant);
|
|
8
|
+
cache.set(variant, built);
|
|
9
|
+
return built;
|
|
10
|
+
};
|
|
11
|
+
}
|