parley-live 0.5.0 → 0.5.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/dist/index.js +2 -2
- package/dist/lens.js +31 -1
- package/lens-builder/build.mjs +23 -4
- package/lens-builder/cli.mjs +6 -0
- package/lens-builder/lib/bridge.mjs +13 -0
- package/lens-builder/lib/submission.mjs +246 -0
- package/lens-builder/publish.mjs +127 -0
- package/lens-builder/vendor/specs-publish/LICENSE +208 -0
- package/lens-builder/vendor/specs-publish/NOTICE +13 -0
- package/lens-builder/vendor/specs-publish/scripts/make-eec-script.py +85 -0
- package/lens-builder/vendor/specs-publish/scripts/package.ts +189 -0
- package/lens-builder/vendor/specs-publish/scripts/preflight.ts +852 -0
- package/lens-builder/vendor/specs-publish/scripts/set-lens-icon.ts +107 -0
- package/lens-builder/vendor/specs-publish/scripts/set-lens-name.ts +121 -0
- package/lens-builder/vendor/specs-publish/scripts/set-signing-key.ts +129 -0
- package/lens-builder/vendor/specs-publish/scripts/signing-key.js +290 -0
- package/lens-builder/verify.mjs +19 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { readFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { api, authWebUrl, backendUrl, clearCredentials, CliError, downloadTo, fileToDataUri, isUrl, loadCredentials, loadLast, openInBrowser, rememberLast, resolveRef, saveCredentials, sleep, } from './client.js';
|
|
20
20
|
import { cmdLens, LensError } from './lens.js';
|
|
21
|
-
const VERSION = '0.5.
|
|
21
|
+
const VERSION = '0.5.1';
|
|
22
22
|
/** Server advice written for Parley's in-app agent names its tools; a CLI user
|
|
23
23
|
* has commands instead, so the same advice is re-spoken in CLI terms. */
|
|
24
24
|
function humanizeServerMessage(message) {
|
|
@@ -1005,7 +1005,7 @@ function help(topic) {
|
|
|
1005
1005
|
engines: 'parley engines\n Which video engines this server can run right now and their credit rates.',
|
|
1006
1006
|
'camera-moves': 'parley camera-moves\n The named camera-move presets accepted by `parley video --move`.',
|
|
1007
1007
|
account: 'parley account\n Your credit balance.',
|
|
1008
|
-
lens: 'parley lens <status|connect|build|describe|record|metrics>\n Build a Snap Specs Lens on this computer through Lens Studio: a step-by-step training guide with pinch buttons and optional voice shortcuts, tested in the preview before you see it. "describe" turns plain language into steps (about a cent, billed to your credits); "build <form.json>" is free. Run `parley lens status` first: it tells you exactly what to set up.',
|
|
1008
|
+
lens: 'parley lens <status|connect|build|describe|publish|record|metrics>\n Build a Snap Specs Lens on this computer through Lens Studio: a step-by-step training guide with pinch buttons and optional voice shortcuts, tested in the preview before you see it. "describe" turns plain language into steps (about a cent, billed to your credits); "build <form.json>" is free. Run `parley lens status` first: it tells you exactly what to set up. `publish` runs Snap\u0027s own preflight and SnapOS Package export (needs Lens Studio signed in).',
|
|
1009
1009
|
};
|
|
1010
1010
|
if (topic && lines[topic]) {
|
|
1011
1011
|
console.log(lines[topic]);
|
package/dist/lens.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* parley lens connect --config '<json>' Save the config Lens Studio copies (AI Assistant > MCP > Configure Server)
|
|
8
8
|
* parley lens build <form.json> Form -> tested Lens ($0, no model call)
|
|
9
9
|
* parley lens describe "<procedure>" Plain language -> steps (billed, about a cent) -> tested Lens
|
|
10
|
+
* parley lens publish [<lens id>] Snap's preflight, then Snap's SnapOS Package export
|
|
10
11
|
* parley lens record <run> [--on-glasses passed|issues] [--published submitted|published]
|
|
11
12
|
* parley lens metrics
|
|
12
13
|
*/
|
|
@@ -198,6 +199,35 @@ export async function cmdLens(positionals, flags, jsonMode) {
|
|
|
198
199
|
throw new LensError('The build did not pass. Read the report.', 6);
|
|
199
200
|
return;
|
|
200
201
|
}
|
|
202
|
+
if (sub === 'publish') {
|
|
203
|
+
const target = positionals[1];
|
|
204
|
+
const events = await runPipeline('publish', { target }, (e) => { if (!jsonMode && e.type === 'log')
|
|
205
|
+
console.log(' ' + e.message); });
|
|
206
|
+
const r = events[events.length - 1];
|
|
207
|
+
if (r.type === 'error')
|
|
208
|
+
throw new LensError(r.error, 1);
|
|
209
|
+
out(r, () => {
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log(`Lens name for Snap: ${r.lensName ?? '(unknown)'}`);
|
|
212
|
+
console.log(`Name, icon and signing key on the project: ${r.readyLocally ? 'all set' : 'incomplete'}`);
|
|
213
|
+
if (r.issues?.length)
|
|
214
|
+
for (const i of r.issues)
|
|
215
|
+
console.log(' - ' + i);
|
|
216
|
+
if (r.ok) {
|
|
217
|
+
console.log(`\nSnapOS Package exported: ${r.spkPath}`);
|
|
218
|
+
if (r.bytes)
|
|
219
|
+
console.log(`${r.bytes} bytes${r.checksum ? ', checksum ' + r.checksum : ''}`);
|
|
220
|
+
}
|
|
221
|
+
else if (r.stage === 'blocked_on_sign_in') {
|
|
222
|
+
console.log('\nNot packaged: Lens Studio is not signed in.');
|
|
223
|
+
}
|
|
224
|
+
for (const n of r.next ?? [])
|
|
225
|
+
console.log('\n' + n);
|
|
226
|
+
});
|
|
227
|
+
if (!r.ok)
|
|
228
|
+
throw new LensError(r.stage === 'blocked_on_sign_in' ? 'Sign in to Lens Studio, then run this again.' : 'The Lens was not packaged.', r.stage === 'blocked_on_sign_in' ? 2 : 6);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
201
231
|
if (sub === 'record') {
|
|
202
232
|
const run = positionals[1];
|
|
203
233
|
const hw = str(flags['on-glasses']);
|
|
@@ -218,5 +248,5 @@ export async function cmdLens(positionals, flags, jsonMode) {
|
|
|
218
248
|
console.log('No Lenses built on this computer yet.'); });
|
|
219
249
|
return;
|
|
220
250
|
}
|
|
221
|
-
throw new LensError(`Unknown: parley lens ${sub}. Use status | connect | build | describe | record | metrics.`, 64);
|
|
251
|
+
throw new LensError(`Unknown: parley lens ${sub}. Use status | connect | build | describe | publish | record | metrics.`, 64);
|
|
222
252
|
}
|
package/lens-builder/build.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { Bridge, ROOT, DATA } from './lib/bridge.mjs';
|
|
7
7
|
import { compile } from './training/compile.mjs';
|
|
8
8
|
import { verify } from './verify.mjs';
|
|
9
|
+
import { prepareSubmission } from './lib/submission.mjs';
|
|
9
10
|
|
|
10
11
|
const MAX_ITERATIONS = 5;
|
|
11
12
|
const here = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'));
|
|
@@ -35,8 +36,10 @@ export async function build(form, { log = console.log, planner = null } = {}) {
|
|
|
35
36
|
await bridge.connect();
|
|
36
37
|
const project = await bridge.newProject(lens_id);
|
|
37
38
|
log(`project: ${project.esproj}`);
|
|
39
|
+
|
|
38
40
|
await bridge.specsPreview();
|
|
39
41
|
|
|
42
|
+
let submission = null;
|
|
40
43
|
let report = null;
|
|
41
44
|
for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration++) {
|
|
42
45
|
result.iterations = iteration;
|
|
@@ -44,9 +47,19 @@ export async function build(form, { log = console.log, planner = null } = {}) {
|
|
|
44
47
|
const authored = await bridge.eecFile(path.join(here, 'training', 'author-scene.ts'), { layout, scriptPath: scriptPath.split(path.sep).join('/') }, 90000);
|
|
45
48
|
log(`iteration ${iteration}: authored ${authored.created.length} objects, script ${authored.script.mode}`);
|
|
46
49
|
|
|
50
|
+
// Snap will not take a Lens without a name it accepts, an icon and a production signing key.
|
|
51
|
+
// A project copied from Snap's Base Template arrives called "SpecsBaseTemplate"; left alone, that is
|
|
52
|
+
// the name that would reach Snap. Done after authoring, not straight after the project opens:
|
|
53
|
+
// writing metadata while Snap's UIKit package is still importing left the package half-resolved
|
|
54
|
+
// (its own textures then failed to load at runtime).
|
|
55
|
+
if (!submission) {
|
|
56
|
+
submission = await prepareSubmission(bridge, { projectDir: project.dir, procedureName: form.procedureName, lensName: form.lensName });
|
|
57
|
+
log(`for Snap: name "${submission.lensName}", icon set, signing key ${submission.signingKeyCreated ? 'generated' : 'reused'}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
// ---- Verifier
|
|
48
61
|
result.stage = 'verify';
|
|
49
|
-
report = await verify({ bridge, plan, form, runDir, iteration, projectDir: project.dir });
|
|
62
|
+
report = await verify({ bridge, plan, form, runDir, iteration, projectDir: project.dir, submission });
|
|
50
63
|
fs.writeFileSync(path.join(runDir, 'verifier', `iteration-${iteration}.json`), JSON.stringify(report, null, 2));
|
|
51
64
|
for (const c of report.checks) log(` ${c.status.toUpperCase().padEnd(8)} ${c.item}${c.detail ? ' -> ' + String(c.detail).slice(0, 160) : ''}`);
|
|
52
65
|
if (report.pass) break;
|
|
@@ -62,7 +75,8 @@ export async function build(form, { log = console.log, planner = null } = {}) {
|
|
|
62
75
|
result.stage = result.ok ? 'done' : 'escalated';
|
|
63
76
|
result.seconds = Math.round((Date.now() - started) / 1000);
|
|
64
77
|
result.costUsd = planner ? planner.costUsd : 0;
|
|
65
|
-
|
|
78
|
+
result.submission = submission;
|
|
79
|
+
fs.writeFileSync(path.join(runDir, 'report.md'), renderReport({ plan, form, report, result, project, planner, submission }));
|
|
66
80
|
fs.writeFileSync(path.join(runDir, 'metrics.json'), JSON.stringify({ lens_id, title: plan.title, vertical: plan.vertical, source: planner ? 'prompt' : 'form', prompt: planner ? planner.prompt : null, pass: result.ok, iterations: result.iterations, seconds: result.seconds + (planner ? planner.seconds : 0), costUsd: result.costUsd, tokens: planner ? planner.usage : { input: 0, output: 0, calls: 0 }, steps: form.steps.length, hardwareItems: report.checks.filter((c) => c.status === 'hardware').length, hardwareTest: 'not tested', publishStatus: 'not published', finished_at: new Date().toISOString() }, null, 2));
|
|
67
81
|
return { ...result, report };
|
|
68
82
|
} catch (e) {
|
|
@@ -73,7 +87,7 @@ export async function build(form, { log = console.log, planner = null } = {}) {
|
|
|
73
87
|
}
|
|
74
88
|
}
|
|
75
89
|
|
|
76
|
-
function renderReport({ plan, form, report, result, project, planner }) {
|
|
90
|
+
function renderReport({ plan, form, report, result, project, planner, submission }) {
|
|
77
91
|
const line = (c) => `| ${c.status === 'pass' ? 'PASS' : c.status === 'fail' ? 'FAIL' : 'NEEDS HARDWARE'} | ${c.item} | ${String(c.detail ?? '').replace(/\|/g, '/').slice(0, 220)} |`;
|
|
78
92
|
const cost = planner
|
|
79
93
|
? `USD ${planner.costUsd.toFixed(4)} (${planner.usage.input} in / ${planner.usage.output} out tokens, ${planner.model}, planning took ${planner.seconds}s)`
|
|
@@ -111,12 +125,17 @@ Source: https://developers.snap.com/spectacles/get-started/start-building/publis
|
|
|
111
125
|
**Tests performed (preview):**
|
|
112
126
|
${report.checks.filter((c) => c.status === 'pass').map((c) => `- ${c.item}`).join('\n')}
|
|
113
127
|
|
|
128
|
+
**Lens name:** ${submission.lensName} (Snap accepts at most 18 characters; the full procedure name stays on the panel)
|
|
129
|
+
**Lens icon:** set, 320x320 PNG
|
|
130
|
+
**Signing key:** production key attached to the project
|
|
131
|
+
|
|
114
132
|
**Tests Snap should repeat on device:** open the Lens, confirm "v${form.version || '1.0.0'}" shows under the buttons, pinch Done through all ${form.steps.length} step${form.steps.length === 1 ? '' : 's'} to the completion message, pinch Back once from step 2${form.voiceShortcuts !== false ? ', say "done" to advance' : ''}.
|
|
115
133
|
|
|
116
134
|
## Manual steps remaining
|
|
117
135
|
- Put the Lens on glasses: pair Specs in Lens Studio, then Send to Spectacles.
|
|
118
136
|
- Check frame rate and thermals on device with the Lens Performance Overlay (Snap targets 60 FPS).
|
|
119
|
-
-
|
|
137
|
+
- Sign in to Lens Studio (My Lenses > Login). That is the one thing Parley cannot do for you, and it gates Snap's preflight, the SnapOS Package export and the upload.
|
|
138
|
+
- Add a preview image or video, then run the publish steps. Name, icon and signing key are already set on this project.
|
|
120
139
|
`;
|
|
121
140
|
}
|
|
122
141
|
|
package/lens-builder/cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// node lens-builder/cli.mjs plan < {"prompt":""} -> { type:'planner', decision, form|question|reason, costUsd }
|
|
4
4
|
// node lens-builder/cli.mjs build < {"form":{}, "planner":{}?} -> progress events, then { type:'result', ... }
|
|
5
5
|
// node lens-builder/cli.mjs metrics -> { type:'metrics', lenses:[...] }
|
|
6
|
+
// node lens-builder/cli.mjs publish [lensId|run] -> Snap preflight + SnapOS Package export (needs Lens Studio sign-in)
|
|
6
7
|
// node lens-builder/cli.mjs record < {"run":"","hardwareTest":"","publishStatus":"","notes":""} -> updated metrics row
|
|
7
8
|
import fs from 'node:fs';
|
|
8
9
|
import path from 'node:path';
|
|
@@ -75,6 +76,11 @@ try {
|
|
|
75
76
|
if (cmd === 'status') await status();
|
|
76
77
|
else if (cmd === 'metrics') metrics();
|
|
77
78
|
else if (cmd === 'record') await record();
|
|
79
|
+
else if (cmd === 'publish') {
|
|
80
|
+
const { publish } = await import('./publish.mjs');
|
|
81
|
+
const { target } = await readStdin();
|
|
82
|
+
emit({ type: 'publish', ...(await publish(target, { log: (message) => emit({ type: 'log', message }) })) });
|
|
83
|
+
}
|
|
78
84
|
else if (cmd === 'plan') {
|
|
79
85
|
const { planFromPrompt } = await import('./planner.mjs');
|
|
80
86
|
const { prompt, auth } = await readStdin();
|
|
@@ -83,6 +83,19 @@ export class Bridge {
|
|
|
83
83
|
return out.returnValue;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Run a script that lives on disk by PATH, without reading it into this process. Snap's publish
|
|
87
|
+
// scripts are meant to run this way (preflight.ts alone is ~28 KB, and reading it costs that twice).
|
|
88
|
+
async eecPath(file, args = {}, timeoutMs = 120000) {
|
|
89
|
+
const out = await this.call('ExecuteEditorCode', { path: file.split(path.sep).join('/'), args, timeoutMs });
|
|
90
|
+
if (out.status !== 'Execution Succeeded') {
|
|
91
|
+
const detail = (out.errors || []).join(' ') || out.error || JSON.stringify(out).slice(0, 800);
|
|
92
|
+
const err = new Error(`${path.basename(file)}: ${out.status}: ${detail}`);
|
|
93
|
+
err.details = out;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
return out.returnValue;
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
eecFile(file, args = {}, timeoutMs = 60000) {
|
|
87
100
|
return this.eec(fs.readFileSync(file, 'utf8'), args, timeoutMs);
|
|
88
101
|
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Everything Snap requires of a Lens before it can be submitted, done at build time.
|
|
2
|
+
//
|
|
3
|
+
// Three of the four requirements can be met on this computer with no Snap account:
|
|
4
|
+
// - a valid Lens name. Snap's Editor.Model.MetaInfo.isLensNameValid accepts at most 18 characters
|
|
5
|
+
// (probed against Lens Studio 5.24.0: 18 ok, 19 rejected; spaces, digits, - : & ' all fine,
|
|
6
|
+
// trailing space rejected). A project copied from Snap's Base Template arrives named
|
|
7
|
+
// "SpecsBaseTemplate", which is what would otherwise reach Snap.
|
|
8
|
+
// - a 320x320 PNG Lens icon (the size Snap's own generate-lens-icon.py produces). Written here
|
|
9
|
+
// with zlib alone so it works from the desktop app and from the npm CLI with no extra dependency.
|
|
10
|
+
// - a production signing key, which Snap's own signing-key.js makes from Node built-ins.
|
|
11
|
+
//
|
|
12
|
+
// The fourth, being signed in to Lens Studio, is Snap's gate: it blocks their preflight, the SPK
|
|
13
|
+
// export and every authorized call. Nothing here pretends to work around it.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import zlib from 'node:zlib';
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
export const SNAP_LENS_NAME_MAX = 18;
|
|
20
|
+
const TRAILING_FILLER = new Set(['and', 'or', 'the', 'a', 'an', 'of', 'for', 'to', 'with', 'on', 'in', 'at', 'by', 'per']);
|
|
21
|
+
|
|
22
|
+
/** A Lens name Snap will accept, derived from the procedure name. Deterministic. */
|
|
23
|
+
export function snapLensName(procedureName, override) {
|
|
24
|
+
const clean = (s) => String(s || '').replace(/[^A-Za-z0-9 \-:&']/g, ' ').replace(/\s+/g, ' ').trim();
|
|
25
|
+
if (override) {
|
|
26
|
+
const o = clean(override);
|
|
27
|
+
if (o && o.length <= SNAP_LENS_NAME_MAX) return o;
|
|
28
|
+
}
|
|
29
|
+
const words = clean(procedureName).split(' ').filter(Boolean);
|
|
30
|
+
if (!words.length) return 'Parley Guide';
|
|
31
|
+
const kept = [];
|
|
32
|
+
for (const w of words) {
|
|
33
|
+
const next = kept.length ? `${kept.join(' ')} ${w}` : w;
|
|
34
|
+
if (next.length > SNAP_LENS_NAME_MAX) break;
|
|
35
|
+
kept.push(w);
|
|
36
|
+
}
|
|
37
|
+
while (kept.length > 1 && TRAILING_FILLER.has(kept[kept.length - 1].toLowerCase())) kept.pop();
|
|
38
|
+
const name = kept.join(' ').trim();
|
|
39
|
+
if (name) return name;
|
|
40
|
+
return clean(procedureName).slice(0, SNAP_LENS_NAME_MAX).trim() || 'Parley Guide';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------- 320x320 PNG, no dependencies
|
|
44
|
+
const CRC_TABLE = (() => {
|
|
45
|
+
const t = new Int32Array(256);
|
|
46
|
+
for (let n = 0; n < 256; n++) {
|
|
47
|
+
let c = n;
|
|
48
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
49
|
+
t[n] = c;
|
|
50
|
+
}
|
|
51
|
+
return t;
|
|
52
|
+
})();
|
|
53
|
+
|
|
54
|
+
function crc32(buf) {
|
|
55
|
+
let c = -1;
|
|
56
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
57
|
+
return (c ^ -1) >>> 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function chunk(type, data) {
|
|
61
|
+
const len = Buffer.alloc(4);
|
|
62
|
+
len.writeUInt32BE(data.length, 0);
|
|
63
|
+
const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
|
|
64
|
+
const crc = Buffer.alloc(4);
|
|
65
|
+
crc.writeUInt32BE(crc32(body), 0);
|
|
66
|
+
return Buffer.concat([len, body, crc]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function encodePng(width, height, rgb) {
|
|
70
|
+
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
71
|
+
const ihdr = Buffer.alloc(13);
|
|
72
|
+
ihdr.writeUInt32BE(width, 0);
|
|
73
|
+
ihdr.writeUInt32BE(height, 4);
|
|
74
|
+
ihdr[8] = 8; // bit depth
|
|
75
|
+
ihdr[9] = 2; // colour type: truecolour
|
|
76
|
+
const raw = Buffer.alloc(height * (1 + width * 3));
|
|
77
|
+
for (let y = 0; y < height; y++) {
|
|
78
|
+
const rowStart = y * (1 + width * 3);
|
|
79
|
+
raw[rowStart] = 0; // filter: none
|
|
80
|
+
rgb.copy(raw, rowStart + 1, y * width * 3, (y + 1) * width * 3);
|
|
81
|
+
}
|
|
82
|
+
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const mix = (a, b, t) => [0, 1, 2].map((i) => Math.round(a[i] + (b[i] - a[i]) * t));
|
|
86
|
+
|
|
87
|
+
function hsl(h, s, l) {
|
|
88
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
89
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
90
|
+
const m = l - c / 2;
|
|
91
|
+
const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
|
|
92
|
+
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The Lens icon: a dark panel with a stack of step bars, the current one lit. It is what the Lens
|
|
97
|
+
* actually is, and it stays legible at the small size a Lens icon is shown at. The accent hue comes
|
|
98
|
+
* from the name, so two procedures never look identical in a list.
|
|
99
|
+
*/
|
|
100
|
+
export function makeLensIcon(lensName, outPath, size = 320) {
|
|
101
|
+
const hash = crypto.createHash('sha256').update(String(lensName)).digest();
|
|
102
|
+
const hue = (hash[0] / 255) * 360;
|
|
103
|
+
const accent = hsl(hue, 0.72, 0.58);
|
|
104
|
+
const accentDim = hsl(hue, 0.38, 0.32);
|
|
105
|
+
const bgTop = [16, 22, 30];
|
|
106
|
+
const bgBottom = [8, 11, 16];
|
|
107
|
+
const panel = [26, 34, 45];
|
|
108
|
+
|
|
109
|
+
const px = Buffer.alloc(size * size * 3);
|
|
110
|
+
const put = (x, y, c) => { const o = (y * size + x) * 3; px[o] = c[0]; px[o + 1] = c[1]; px[o + 2] = c[2]; };
|
|
111
|
+
|
|
112
|
+
// rounded-rect membership with a soft edge, so nothing looks jagged at icon size
|
|
113
|
+
const inRounded = (x, y, rx, ry, rw, rh, r) => {
|
|
114
|
+
const cx = Math.min(Math.max(x, rx + r), rx + rw - r);
|
|
115
|
+
const cy = Math.min(Math.max(y, ry + r), ry + rh - r);
|
|
116
|
+
const d = Math.hypot(x - cx, y - cy);
|
|
117
|
+
if (x < rx || x > rx + rw || y < ry || y > ry + rh) return 0;
|
|
118
|
+
return d <= r - 0.5 ? 1 : d >= r + 0.5 ? 0 : r + 0.5 - d;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const s = size / 320;
|
|
122
|
+
const panelX = 44 * s, panelY = 60 * s, panelW = 232 * s, panelH = 200 * s, panelR = 26 * s;
|
|
123
|
+
const barX = 70 * s, barR = 7 * s, barH = 14 * s;
|
|
124
|
+
const bars = [
|
|
125
|
+
{ y: 96 * s, w: 180 * s, on: true },
|
|
126
|
+
{ y: 136 * s, w: 144 * s, on: false },
|
|
127
|
+
{ y: 176 * s, w: 162 * s, on: false },
|
|
128
|
+
{ y: 216 * s, w: 110 * s, on: false },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
for (let y = 0; y < size; y++) {
|
|
132
|
+
const ground = mix(bgTop, bgBottom, y / (size - 1));
|
|
133
|
+
for (let x = 0; x < size; x++) {
|
|
134
|
+
let c = ground;
|
|
135
|
+
const inPanel = inRounded(x, y, panelX, panelY, panelW, panelH, panelR);
|
|
136
|
+
if (inPanel > 0) c = mix(c, panel, inPanel);
|
|
137
|
+
for (const b of bars) {
|
|
138
|
+
const a = inRounded(x, y, barX, b.y, b.w, barH, barR);
|
|
139
|
+
if (a > 0) c = mix(c, b.on ? accent : accentDim, a);
|
|
140
|
+
}
|
|
141
|
+
put(x, y, c);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
145
|
+
fs.writeFileSync(outPath, encodePng(size, size, px));
|
|
146
|
+
return { path: outPath, size, accent: `#${accent.map((v) => v.toString(16).padStart(2, '0')).join('')}` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------- applied inside Lens Studio
|
|
150
|
+
const SET_METADATA = `const E: any = (globalThis as any).Editor;
|
|
151
|
+
const a: any = args;
|
|
152
|
+
const FileSystem: any = await import("LensStudio:FileSystem");
|
|
153
|
+
const model: any = pluginSystem.findInterface(E.Model.IModel);
|
|
154
|
+
const project: any = model.project;
|
|
155
|
+
const before = String(project.metaInfo.lensName || "");
|
|
156
|
+
if (!E.Model.MetaInfo.isLensNameValid(String(a.lensName))) {
|
|
157
|
+
return { error: "Lens Studio rejected the Lens name: " + a.lensName };
|
|
158
|
+
}
|
|
159
|
+
let meta: any = project.metaInfo;
|
|
160
|
+
meta.lensName = String(a.lensName);
|
|
161
|
+
project.metaInfo = meta;
|
|
162
|
+
if (a.iconPath) {
|
|
163
|
+
const src = new E.Path(String(a.iconPath));
|
|
164
|
+
if (!FileSystem.exists(src)) return { error: "Lens icon not found: " + a.iconPath };
|
|
165
|
+
meta = project.metaInfo;
|
|
166
|
+
meta.setIcon(src);
|
|
167
|
+
project.metaInfo = meta;
|
|
168
|
+
}
|
|
169
|
+
if (a.signingKeyPath) {
|
|
170
|
+
const key = new E.Path(String(a.signingKeyPath));
|
|
171
|
+
if (!FileSystem.exists(key)) return { error: "Signing key not found: " + a.signingKeyPath };
|
|
172
|
+
meta = project.metaInfo;
|
|
173
|
+
meta.spkProductionKeyPath = key;
|
|
174
|
+
project.metaInfo = meta;
|
|
175
|
+
}
|
|
176
|
+
project.save();
|
|
177
|
+
const keyOut = project.metaInfo.spkProductionKeyPath;
|
|
178
|
+
return {
|
|
179
|
+
before,
|
|
180
|
+
lensName: String(project.metaInfo.lensName || ""),
|
|
181
|
+
iconSet: !!project.metaInfo.isIconSet,
|
|
182
|
+
iconPath: String(project.metaInfo.iconPath || ""),
|
|
183
|
+
signingKeyPath: keyOut && !keyOut.isEmpty ? keyOut.toString() : "",
|
|
184
|
+
};`;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The production signing key Snap's SPK export needs: an ed25519 private key as pkcs8 PEM, which is
|
|
188
|
+
* the exact shape Snap's own signing-key.js writes. Node's crypto makes it; no account, no network.
|
|
189
|
+
* Reused when one is already on disk, so rebuilding a Lens keeps its identity.
|
|
190
|
+
*/
|
|
191
|
+
export function ensureSigningKey(projectDir) {
|
|
192
|
+
const keyPath = path.join(projectDir, 'specs_signing_key.pem');
|
|
193
|
+
if (fs.existsSync(keyPath) && /BEGIN PRIVATE KEY/.test(fs.readFileSync(keyPath, 'utf8'))) {
|
|
194
|
+
return { path: keyPath, created: false };
|
|
195
|
+
}
|
|
196
|
+
const { privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
197
|
+
fs.writeFileSync(keyPath, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
|
|
198
|
+
try { fs.chmodSync(keyPath, 0o600); } catch { /* windows */ }
|
|
199
|
+
return { path: keyPath, created: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Give the freshly built project the Lens name, icon and signing key Snap asks for.
|
|
204
|
+
* Returns what was set, so the verifier can check it rather than assume it.
|
|
205
|
+
*/
|
|
206
|
+
export async function prepareSubmission(bridge, { projectDir, procedureName, lensName }) {
|
|
207
|
+
const name = snapLensName(procedureName, lensName);
|
|
208
|
+
const icon = makeLensIcon(name, path.join(projectDir, 'Cache', 'parley-lens-icon.png'));
|
|
209
|
+
const key = ensureSigningKey(projectDir);
|
|
210
|
+
const fwd = (p) => p.split(path.sep).join('/');
|
|
211
|
+
const applied = await bridge.eec(SET_METADATA, { lensName: name, iconPath: fwd(icon.path), signingKeyPath: fwd(key.path) }, 90000);
|
|
212
|
+
if (applied?.error) throw new Error(applied.error);
|
|
213
|
+
return {
|
|
214
|
+
lensName: applied.lensName,
|
|
215
|
+
inheritedName: applied.before,
|
|
216
|
+
nameWasTemplateDefault: /^SpecsBaseTemplate$/i.test(applied.before || ''),
|
|
217
|
+
iconPath: applied.iconPath,
|
|
218
|
+
iconAccent: icon.accent,
|
|
219
|
+
signingKeyPath: applied.signingKeyPath,
|
|
220
|
+
signingKeyCreated: key.created,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Read back what Snap would see, including whether Lens Studio is signed in. */
|
|
225
|
+
export function readSubmissionState(bridge) {
|
|
226
|
+
return bridge.eec(`const E: any = (globalThis as any).Editor;
|
|
227
|
+
const model: any = pluginSystem.findInterface(E.Model.IModel);
|
|
228
|
+
const project: any = model.project;
|
|
229
|
+
const FileSystem: any = await import("LensStudio:FileSystem");
|
|
230
|
+
let signedIn: any = null;
|
|
231
|
+
try {
|
|
232
|
+
const auth: any = pluginSystem.findInterface(E.IAuthorization.interfaceId);
|
|
233
|
+
signedIn = !E.isNull(auth) && !!auth.isAuthorized;
|
|
234
|
+
} catch (e) { signedIn = null; }
|
|
235
|
+
const name = String(project.metaInfo.lensName || "");
|
|
236
|
+
const icon = String(project.metaInfo.iconPath || "");
|
|
237
|
+
const key = project.metaInfo.spkProductionKeyPath;
|
|
238
|
+
return {
|
|
239
|
+
lensName: name,
|
|
240
|
+
lensNameValid: !!name && E.Model.MetaInfo.isLensNameValid(name) && name.toLowerCase() !== "untitled" && name !== "SpecsBaseTemplate",
|
|
241
|
+
iconPath: icon,
|
|
242
|
+
iconPresent: !!project.metaInfo.isIconSet && !!icon && FileSystem.exists(new E.Path(icon)),
|
|
243
|
+
productionKeySet: !!key && !key.isEmpty && FileSystem.exists(key),
|
|
244
|
+
signedIn,
|
|
245
|
+
};`, {}, 60000);
|
|
246
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Takes a built Lens as far towards Snap as this computer can: Snap's own preflight, then Snap's own
|
|
2
|
+
// production SnapOS Package (SPK) export. Both run from lens-builder/vendor/specs-publish, which is
|
|
3
|
+
// Snap's Apache-2.0 toolchain copied unmodified; see that folder's NOTICE.
|
|
4
|
+
//
|
|
5
|
+
// The wall is Lens Studio's own sign-in. Snap's preflight refuses without it and their exporter checks
|
|
6
|
+
// Editor.IAuthorization.isAuthorized directly, so no Lens can be packaged for Snap by anyone, through
|
|
7
|
+
// Parley or by hand, until someone signs in to Lens Studio. Everything on this side of that line
|
|
8
|
+
// (a Lens name Snap accepts, the icon, the production signing key) is done at build time.
|
|
9
|
+
//
|
|
10
|
+
// Registering and submitting the release is deliberately not automated: it is irreversible and needs
|
|
11
|
+
// choices only the owner can make (organisation, category, content rating).
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { Bridge, DATA } from './lib/bridge.mjs';
|
|
16
|
+
import { readSubmissionState } from './lib/submission.mjs';
|
|
17
|
+
|
|
18
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const SNAP_SCRIPTS = path.join(HERE, 'vendor', 'specs-publish', 'scripts');
|
|
20
|
+
const fwd = (p) => p.split(path.sep).join('/');
|
|
21
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
|
|
23
|
+
/** The .esproj for a lens id, a run folder name, or an explicit path. */
|
|
24
|
+
export function resolveProject(target) {
|
|
25
|
+
if (!target) return null;
|
|
26
|
+
if (/\.esproj$/i.test(target) && fs.existsSync(target)) return path.resolve(target);
|
|
27
|
+
const asRun = path.join(DATA, 'runs', target, 'metrics.json');
|
|
28
|
+
const lensId = fs.existsSync(asRun) ? JSON.parse(fs.readFileSync(asRun, 'utf8')).lens_id : target;
|
|
29
|
+
const dir = path.join(DATA, 'lens-projects', lensId);
|
|
30
|
+
const esproj = path.join(dir, `${lensId}.esproj`);
|
|
31
|
+
return fs.existsSync(esproj) ? esproj : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function openProject(bridge, esproj) {
|
|
35
|
+
const current = await bridge.projectInfo().catch(() => null);
|
|
36
|
+
if (current && fwd(current.file).toLowerCase() === fwd(esproj).toLowerCase()) return current;
|
|
37
|
+
await bridge.eec(`const E: any = (globalThis as any).Editor; const model: any = pluginSystem.findInterface(E.Model.IModel);
|
|
38
|
+
model.openProject(new E.Path(String((args as any).file))); return "opening";`, { file: fwd(esproj) });
|
|
39
|
+
for (let i = 0; i < 30; i++) {
|
|
40
|
+
await sleep(2000);
|
|
41
|
+
const info = await bridge.projectInfo().catch(() => null);
|
|
42
|
+
if (info && fwd(info.file).toLowerCase() === fwd(esproj).toLowerCase()) { await sleep(2000); return info; }
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Timed out opening ${esproj}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SIGN_IN_HELP = 'Sign in to Lens Studio: My Lenses > Login, in the Lens Studio menu bar. That is Snap\'s gate. Until someone signs in, no Lens can be packaged or submitted for Snap by any route, and Parley cannot do it for you.';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param target lens id, run folder name, or a path to a .esproj. Omit to use whatever is open.
|
|
51
|
+
* @returns { stage, ok, signedIn, ready, spkPath, checksum, issues[], next[] }
|
|
52
|
+
*/
|
|
53
|
+
export async function publish(target, { log = () => {} } = {}) {
|
|
54
|
+
const out = { stage: 'preflight', ok: false, signedIn: null, ready: false, spkPath: null, checksum: null, issues: [], next: [] };
|
|
55
|
+
const bridge = new Bridge();
|
|
56
|
+
await bridge.connect();
|
|
57
|
+
|
|
58
|
+
const esproj = resolveProject(target);
|
|
59
|
+
if (target && !esproj) throw new Error(`No built Lens found for "${target}". Run parley lens metrics to see what is on this computer.`);
|
|
60
|
+
if (esproj) { log(`opening ${path.basename(esproj)}`); await openProject(bridge, esproj); }
|
|
61
|
+
const info = await bridge.projectInfo();
|
|
62
|
+
out.project = info.file;
|
|
63
|
+
|
|
64
|
+
// What Parley set at build time, read back from the project rather than assumed.
|
|
65
|
+
const state = await readSubmissionState(bridge).catch(() => null);
|
|
66
|
+
if (state) {
|
|
67
|
+
out.signedIn = state.signedIn;
|
|
68
|
+
out.lensName = state.lensName;
|
|
69
|
+
out.readyLocally = !!(state.lensNameValid && state.iconPresent && state.productionKeySet);
|
|
70
|
+
if (!state.lensNameValid) out.issues.push(`The Lens name "${state.lensName}" is not one Snap accepts. Rebuild the Lens so Parley sets it.`);
|
|
71
|
+
if (!state.iconPresent) out.issues.push('No Lens icon on the project. Rebuild the Lens so Parley sets it.');
|
|
72
|
+
if (!state.productionKeySet) out.issues.push('No production signing key on the project. Rebuild the Lens so Parley attaches one.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Snap's own preflight. It returns immediately on auth, so its verdict on the rest is only
|
|
76
|
+
// visible once signed in; Parley checks name, icon and key itself above regardless.
|
|
77
|
+
log('running Snap\'s preflight');
|
|
78
|
+
const pre = await bridge.eecPath(path.join(SNAP_SCRIPTS, 'preflight.ts'), {}, 120000).catch((e) => ({ status: 'FAILED', reason: 'preflight_error', message: e.message }));
|
|
79
|
+
out.preflight = pre;
|
|
80
|
+
|
|
81
|
+
if (pre?.status === 'ACTION_REQUIRED' && pre.reason === 'not_signed_in') {
|
|
82
|
+
out.stage = 'blocked_on_sign_in';
|
|
83
|
+
out.signedIn = false;
|
|
84
|
+
out.next = [SIGN_IN_HELP, 'Then run this command again. Nothing else is missing on this computer.'];
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
if (pre?.status === 'ACTION_REQUIRED') {
|
|
88
|
+
out.stage = 'action_required';
|
|
89
|
+
out.issues.push(...(pre.issues?.map((i) => i.message) ?? [pre.message]).filter(Boolean));
|
|
90
|
+
out.next = ['Snap needs these before the Lens can be packaged. Fix them in Lens Studio, then run this again.'];
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
if (pre?.status !== 'READY') {
|
|
94
|
+
out.stage = 'preflight_failed';
|
|
95
|
+
out.issues.push(pre?.message || 'Snap\'s preflight did not return READY.');
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
out.ready = true;
|
|
100
|
+
out.signedIn = true;
|
|
101
|
+
out.packageId = pre.packageId;
|
|
102
|
+
|
|
103
|
+
// Snap's own exporter. Produces <project>/.export/<name>.spk, signed with the production key.
|
|
104
|
+
log('exporting the SnapOS Package');
|
|
105
|
+
out.stage = 'package';
|
|
106
|
+
const pkg = await bridge.eecPath(path.join(SNAP_SCRIPTS, 'package.ts'), {}, 300000);
|
|
107
|
+
out.package = pkg;
|
|
108
|
+
if (pkg?.status && pkg.status !== 'PACKAGED' && pkg.status !== 'READY') {
|
|
109
|
+
out.issues.push(pkg.message || 'The SnapOS Package export did not complete.');
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
out.spkPath = pkg?.spkPath || pkg?.packagePath || null;
|
|
113
|
+
out.checksum = pkg?.spkChecksum || pkg?.checksum || null;
|
|
114
|
+
out.bytes = pkg?.packageBytes ?? (out.spkPath && fs.existsSync(out.spkPath) ? fs.statSync(out.spkPath).size : null);
|
|
115
|
+
out.ok = !!out.spkPath;
|
|
116
|
+
out.stage = out.ok ? 'packaged' : 'package_failed';
|
|
117
|
+
out.next = out.ok
|
|
118
|
+
? ['Register and submit the release from Lens Studio, or with Snap\'s authorized-request script. That step is irreversible and needs your organisation, category and content rating, so Parley stops here.']
|
|
119
|
+
: ['The export did not produce a package. Read the message above.'];
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(HERE, 'publish.mjs')) {
|
|
124
|
+
const r = await publish(process.argv[2], { log: (m) => console.log(' ' + m) });
|
|
125
|
+
console.log(JSON.stringify(r, null, 2));
|
|
126
|
+
process.exitCode = r.ok ? 0 : r.stage === 'blocked_on_sign_in' ? 3 : 1;
|
|
127
|
+
}
|