@notis_ai/cli 0.2.0-beta.178.1 → 0.2.0-beta.181.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/agent-hooks/notis-agent-hook.mjs +138 -17
- package/dist/base-skills/notis-apps/SKILL.md +2 -0
- package/dist/base-skills/notis-apps/references/architecture.md +5 -2
- package/dist/base-skills/notis-apps/references/release.md +44 -0
- package/dist/sdk/src/runtime.ts +2 -0
- package/package.json +1 -1
- package/src/command-specs/apps.js +10 -2
- package/src/runtime/agent-browser.js +62 -22
- package/src/runtime/screenshot-framing.js +85 -0
- package/src/runtime/store-screenshot.js +10 -7
- package/template/.harness/index.html.tmpl +7 -10
|
@@ -11748,7 +11748,79 @@ async function startAppTestServer({ apps, port, harness = {} }) {
|
|
|
11748
11748
|
import { spawn as spawn2, spawnSync } from "node:child_process";
|
|
11749
11749
|
import { mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
|
|
11750
11750
|
import { dirname as dirname5 } from "node:path";
|
|
11751
|
-
|
|
11751
|
+
|
|
11752
|
+
// src/runtime/screenshot-framing.js
|
|
11753
|
+
function measureScreenshotContent() {
|
|
11754
|
+
const root = document.getElementById("root");
|
|
11755
|
+
if (!root) return null;
|
|
11756
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
11757
|
+
const visible = (element) => {
|
|
11758
|
+
const rect2 = element.getBoundingClientRect();
|
|
11759
|
+
const style = getComputedStyle(element);
|
|
11760
|
+
return rect2.width > 0 && rect2.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
|
11761
|
+
};
|
|
11762
|
+
let node = root;
|
|
11763
|
+
let content = null;
|
|
11764
|
+
while (node) {
|
|
11765
|
+
const hasOwnText = Array.from(node.childNodes).some(
|
|
11766
|
+
(child) => child.nodeType === 3 && child.textContent.trim()
|
|
11767
|
+
);
|
|
11768
|
+
const children = Array.from(node.children).filter(visible);
|
|
11769
|
+
if (hasOwnText || children.length !== 1) break;
|
|
11770
|
+
node = children[0];
|
|
11771
|
+
const rect2 = node.getBoundingClientRect();
|
|
11772
|
+
if (["DIV", "MAIN", "SECTION", "ARTICLE", "FORM"].includes(node.tagName) && rect2.width >= 320 && rect2.height >= 160 && rect2.width < viewportWidth * 0.9) {
|
|
11773
|
+
content = node;
|
|
11774
|
+
}
|
|
11775
|
+
}
|
|
11776
|
+
if (!content) return null;
|
|
11777
|
+
content.setAttribute("data-notis-screenshot-content", "");
|
|
11778
|
+
const rect = content.getBoundingClientRect();
|
|
11779
|
+
return {
|
|
11780
|
+
selector: "[data-notis-screenshot-content]",
|
|
11781
|
+
width: rect.width,
|
|
11782
|
+
height: rect.height,
|
|
11783
|
+
viewport_width: viewportWidth
|
|
11784
|
+
};
|
|
11785
|
+
}
|
|
11786
|
+
var MEASURE_SCREENSHOT_CONTENT_SCRIPT = `(${measureScreenshotContent.toString()})()`;
|
|
11787
|
+
function contentCapturePlan(bounds, viewport, outputWidth, outputHeight) {
|
|
11788
|
+
const aspect = outputWidth / outputHeight;
|
|
11789
|
+
if (bounds.width / bounds.height < aspect * 0.75) {
|
|
11790
|
+
const width = Math.max(768, Math.ceil((bounds.width + 32) / 8) * 8);
|
|
11791
|
+
return { focus: false, width, height: Math.round(width / aspect), scale: outputWidth / width };
|
|
11792
|
+
}
|
|
11793
|
+
return {
|
|
11794
|
+
focus: true,
|
|
11795
|
+
width: viewport.width,
|
|
11796
|
+
height: viewport.height,
|
|
11797
|
+
scale: Math.min(4, outputWidth / bounds.width)
|
|
11798
|
+
};
|
|
11799
|
+
}
|
|
11800
|
+
function safeViewportHeight(rectangles, height) {
|
|
11801
|
+
const crossing = rectangles.filter((rect) => rect.top < height && rect.bottom > height && rect.top >= height * 0.85);
|
|
11802
|
+
return crossing.length ? Math.max(Math.ceil(height * 0.85), Math.floor(Math.min(...crossing.map((rect) => rect.top)) - 8)) : height;
|
|
11803
|
+
}
|
|
11804
|
+
var SAFE_VIEWPORT_HEIGHT_SCRIPT = `(() => {
|
|
11805
|
+
const root = document.getElementById('root');
|
|
11806
|
+
if (!root) return innerHeight;
|
|
11807
|
+
const rects = [];
|
|
11808
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
11809
|
+
let node;
|
|
11810
|
+
while ((node = walker.nextNode())) {
|
|
11811
|
+
if (!node.textContent.trim() || getComputedStyle(node.parentElement).visibility !== 'visible') continue;
|
|
11812
|
+
const range = document.createRange(); range.selectNodeContents(node);
|
|
11813
|
+
for (const rect of range.getClientRects()) rects.push({ top: rect.top, bottom: rect.bottom });
|
|
11814
|
+
}
|
|
11815
|
+
for (const control of root.querySelectorAll('button,input,select,textarea')) {
|
|
11816
|
+
const rect = control.getBoundingClientRect();
|
|
11817
|
+
rects.push({ top: rect.top, bottom: rect.bottom });
|
|
11818
|
+
}
|
|
11819
|
+
return (${safeViewportHeight.toString()})(rects, innerHeight);
|
|
11820
|
+
})()`;
|
|
11821
|
+
|
|
11822
|
+
// src/runtime/agent-browser.js
|
|
11823
|
+
var PREPARE_CAPTURE_SCRIPT = "(() => { const r = document.getElementById('root'); if (r) r.style.minHeight = '0'; document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
|
|
11752
11824
|
var FONTS_STATUS_SCRIPT = "(() => (document.fonts ? document.fonts.status : 'loaded'))()";
|
|
11753
11825
|
var CONTENT_HEIGHT_SCRIPT = "(() => { const r = document.getElementById('root'); if (!r) return Math.ceil(document.documentElement.scrollHeight); return Math.ceil(Math.max(r.scrollHeight, r.getBoundingClientRect().height)); })()";
|
|
11754
11826
|
var MIN_CSS_VIEWPORT_HEIGHT = 720;
|
|
@@ -12019,6 +12091,7 @@ async function captureHarnessScreenshot({
|
|
|
12019
12091
|
sessionName,
|
|
12020
12092
|
screenshotPath,
|
|
12021
12093
|
focusSelector = null,
|
|
12094
|
+
frameContent = false,
|
|
12022
12095
|
width = 2e3,
|
|
12023
12096
|
height = 1250,
|
|
12024
12097
|
timeoutMs = 15e3
|
|
@@ -12103,12 +12176,49 @@ async function captureHarnessScreenshot({
|
|
|
12103
12176
|
}
|
|
12104
12177
|
}
|
|
12105
12178
|
await delay(500);
|
|
12179
|
+
let contentBounds = null;
|
|
12180
|
+
if (frameContent && !focusSelector) {
|
|
12181
|
+
const measured = await runAgentBrowser(
|
|
12182
|
+
["--session", sessionName, "--json", "eval", MEASURE_SCREENSHOT_CONTENT_SCRIPT],
|
|
12183
|
+
{ timeoutMs: 5e3 }
|
|
12184
|
+
);
|
|
12185
|
+
if (measured.exitCode === 0) {
|
|
12186
|
+
try {
|
|
12187
|
+
const bounds = parseHarnessFromEval(measured.stdout);
|
|
12188
|
+
if (bounds?.selector === "[data-notis-screenshot-content]" && Number.isFinite(bounds.width) && bounds.width > 0 && Number.isFinite(bounds.height) && bounds.height > 0) {
|
|
12189
|
+
contentBounds = bounds;
|
|
12190
|
+
}
|
|
12191
|
+
} catch {
|
|
12192
|
+
}
|
|
12193
|
+
}
|
|
12194
|
+
}
|
|
12195
|
+
let captureFocus = focusSelector || contentBounds?.selector || null;
|
|
12196
|
+
if (contentBounds && scaledViewport) {
|
|
12197
|
+
const plan = contentCapturePlan(contentBounds, framing || initial, width, height);
|
|
12198
|
+
if (await setViewport(sessionName, plan.width, plan.height, plan.scale)) {
|
|
12199
|
+
await delay(300);
|
|
12200
|
+
if (!plan.focus) {
|
|
12201
|
+
const safeHeight = await evalNumber(sessionName, SAFE_VIEWPORT_HEIGHT_SCRIPT, plan.height);
|
|
12202
|
+
if (safeHeight < plan.height && await setViewport(sessionName, plan.width, safeHeight, plan.scale)) {
|
|
12203
|
+
plan.height = safeHeight;
|
|
12204
|
+
}
|
|
12205
|
+
}
|
|
12206
|
+
captureFocus = plan.focus ? contentBounds.selector : null;
|
|
12207
|
+
framing = {
|
|
12208
|
+
...framing || {},
|
|
12209
|
+
...plan,
|
|
12210
|
+
content_bounds: contentBounds,
|
|
12211
|
+
capture_mode: plan.focus ? "content" : "width-fit-viewport"
|
|
12212
|
+
};
|
|
12213
|
+
await delay(300);
|
|
12214
|
+
}
|
|
12215
|
+
}
|
|
12106
12216
|
const designResult = await evalDesignAssertions(sessionName, { viewport: "capture" });
|
|
12107
12217
|
const design = Array.isArray(designResult) ? designResult : [];
|
|
12108
12218
|
mkdirSync3(dirname5(screenshotPath), { recursive: true });
|
|
12109
12219
|
const screenshotArgs = ["--session", sessionName, "screenshot"];
|
|
12110
|
-
if (
|
|
12111
|
-
screenshotArgs.push(
|
|
12220
|
+
if (captureFocus) {
|
|
12221
|
+
screenshotArgs.push(captureFocus);
|
|
12112
12222
|
}
|
|
12113
12223
|
screenshotArgs.push(screenshotPath, "--screenshot-format", "png");
|
|
12114
12224
|
const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15e3 });
|
|
@@ -12116,7 +12226,9 @@ async function captureHarnessScreenshot({
|
|
|
12116
12226
|
return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError("screenshot", shot) };
|
|
12117
12227
|
}
|
|
12118
12228
|
const dimensions = pngDimensions2(screenshotPath);
|
|
12119
|
-
|
|
12229
|
+
const expectedDimensions = framing?.capture_mode === "width-fit-viewport" ? { width: Math.round(framing.width * framing.scale), height: Math.round(framing.height * framing.scale) } : { width, height };
|
|
12230
|
+
const tolerance = frameContent ? 1 : 0;
|
|
12231
|
+
if (!captureFocus && framing && (!dimensions || Math.abs(dimensions.width - expectedDimensions.width) > tolerance || Math.abs(dimensions.height - expectedDimensions.height) > tolerance)) {
|
|
12120
12232
|
await setViewport(sessionName, width, height);
|
|
12121
12233
|
await delay(300);
|
|
12122
12234
|
const retry = await runAgentBrowser(
|
|
@@ -12128,10 +12240,11 @@ async function captureHarnessScreenshot({
|
|
|
12128
12240
|
}
|
|
12129
12241
|
framing = null;
|
|
12130
12242
|
}
|
|
12131
|
-
if (
|
|
12243
|
+
if (captureFocus) {
|
|
12132
12244
|
framing = {
|
|
12133
12245
|
...framing || {},
|
|
12134
|
-
focus_selector:
|
|
12246
|
+
focus_selector: captureFocus,
|
|
12247
|
+
content_bounds: contentBounds,
|
|
12135
12248
|
source_dimensions: dimensions
|
|
12136
12249
|
};
|
|
12137
12250
|
}
|
|
@@ -12187,7 +12300,7 @@ var DESIGN_ASSERTIONS_SCRIPT = `(() => {
|
|
|
12187
12300
|
};
|
|
12188
12301
|
const all = Array.from(root.querySelectorAll('*'));
|
|
12189
12302
|
for (const el of all) {
|
|
12190
|
-
if (
|
|
12303
|
+
if (!visible(el) || isControl(el)) continue;
|
|
12191
12304
|
if (isBox(el)) {
|
|
12192
12305
|
let p = el.parentElement; let depth = 0;
|
|
12193
12306
|
while (p && p !== root && depth < 6) { if (!isControl(p) && isBox(p)) { push('nested_border_box', el); break; } p = p.parentElement; depth += 1; }
|
|
@@ -12204,7 +12317,7 @@ var DESIGN_ASSERTIONS_SCRIPT = `(() => {
|
|
|
12204
12317
|
const text = (node.textContent || '').trim();
|
|
12205
12318
|
if (!text) continue;
|
|
12206
12319
|
const el = node.parentElement;
|
|
12207
|
-
if (!el ||
|
|
12320
|
+
if (!el || !visible(el)) continue;
|
|
12208
12321
|
const size = parseFloat(getComputedStyle(el).fontSize);
|
|
12209
12322
|
if (size > 0 && size < 12) push('text_below_12px', el, { font_size: size });
|
|
12210
12323
|
if (/^Loading(\\.{3}|\\u2026)?(\\s|$)/.test(text)) push('loading_placeholder_after_mount', el);
|
|
@@ -12322,15 +12435,16 @@ function resolveStoreScreenshotAccent(accent, seed = "notis-app") {
|
|
|
12322
12435
|
}
|
|
12323
12436
|
return ACCENT_NAMES[stableHash(seed) % ACCENT_NAMES.length];
|
|
12324
12437
|
}
|
|
12325
|
-
function storeScreenshotLayout(width = 2e3, height = 1250) {
|
|
12438
|
+
function storeScreenshotLayout(width = 2e3, height = 1250, contentAspect = STORE_SCREENSHOT_ASPECT) {
|
|
12439
|
+
const aspect = Number.isFinite(contentAspect) && contentAspect > 0 ? contentAspect : STORE_SCREENSHOT_ASPECT;
|
|
12326
12440
|
const horizontalInset = Math.max(48, Math.round(width * 0.06));
|
|
12327
12441
|
const verticalInset = Math.max(40, Math.round(height * 0.06));
|
|
12328
12442
|
let cardWidth = width - horizontalInset * 2;
|
|
12329
|
-
let cardHeight = Math.round(cardWidth /
|
|
12443
|
+
let cardHeight = Math.round(cardWidth / aspect);
|
|
12330
12444
|
const maximumCardHeight = height - verticalInset * 2;
|
|
12331
12445
|
if (cardHeight > maximumCardHeight) {
|
|
12332
12446
|
cardHeight = maximumCardHeight;
|
|
12333
|
-
cardWidth = Math.round(cardHeight *
|
|
12447
|
+
cardWidth = Math.round(cardHeight * aspect);
|
|
12334
12448
|
}
|
|
12335
12449
|
const cardLeft = Math.round((width - cardWidth) / 2);
|
|
12336
12450
|
const cardTop = Math.round((height - cardHeight) / 2) - Math.round(height * 8e-3);
|
|
@@ -12366,11 +12480,12 @@ async function composeStoreScreenshot({
|
|
|
12366
12480
|
}) {
|
|
12367
12481
|
const sharp = await loadSharp();
|
|
12368
12482
|
const resolvedTheme = theme === "dark" ? "dark" : "light";
|
|
12369
|
-
const
|
|
12483
|
+
const source = await sharp(inputPath).metadata();
|
|
12484
|
+
const layout = storeScreenshotLayout(width, height, source.width / source.height);
|
|
12370
12485
|
const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
|
|
12371
12486
|
const roundedCapture = await sharp(inputPath).resize(layout.cardWidth, layout.cardHeight, {
|
|
12372
|
-
fit: "
|
|
12373
|
-
|
|
12487
|
+
fit: "contain",
|
|
12488
|
+
background: resolvedTheme === "dark" ? "#09090b" : "#ffffff"
|
|
12374
12489
|
}).composite([{
|
|
12375
12490
|
input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),
|
|
12376
12491
|
blend: "dest-in"
|
|
@@ -13776,6 +13891,7 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
|
|
|
13776
13891
|
sessionName: captureSessionName,
|
|
13777
13892
|
screenshotPath: browserScreenshotPath,
|
|
13778
13893
|
focusSelector: ctx.options.raw ? null : focus,
|
|
13894
|
+
frameContent: !ctx.options.raw,
|
|
13779
13895
|
width,
|
|
13780
13896
|
height,
|
|
13781
13897
|
timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || "", 10) || 15e3
|
|
@@ -13790,7 +13906,7 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
|
|
|
13790
13906
|
height,
|
|
13791
13907
|
accent: appConfig.accent,
|
|
13792
13908
|
seed: appConfig.name || manifest.app?.name || appSlug,
|
|
13793
|
-
focused: Boolean(
|
|
13909
|
+
focused: Boolean(result.framing?.focus_selector),
|
|
13794
13910
|
theme: theme || "light"
|
|
13795
13911
|
});
|
|
13796
13912
|
} catch (error) {
|
|
@@ -14085,15 +14201,20 @@ async function appsDeployHandler(ctx) {
|
|
|
14085
14201
|
} catch {
|
|
14086
14202
|
warnings.push("The app was updated, but the temporary release directory needs local cleanup.");
|
|
14087
14203
|
}
|
|
14204
|
+
const skillReviews = (Array.isArray(result.payload.skill_reviews) ? result.payload.skill_reviews : []).filter((review) => review && typeof review.review_url === "string");
|
|
14088
14205
|
return ctx.output.emitSuccess({
|
|
14089
14206
|
command: ctx.spec.command_path.join(" "),
|
|
14090
14207
|
data: {
|
|
14091
14208
|
app_id: appId,
|
|
14092
14209
|
version: deployedVersion,
|
|
14093
|
-
idempotency_key: idempotencyKey
|
|
14210
|
+
idempotency_key: idempotencyKey,
|
|
14211
|
+
...skillReviews.length ? { skill_reviews: skillReviews } : {}
|
|
14094
14212
|
},
|
|
14095
14213
|
warnings,
|
|
14096
|
-
humanSummary:
|
|
14214
|
+
humanSummary: [
|
|
14215
|
+
`Deployed to app ${appId} (version ${deployedVersion})`,
|
|
14216
|
+
...skillReviews.map((review) => `Review ${review.skill} changes: ${review.review_url}`)
|
|
14217
|
+
].join("\n"),
|
|
14097
14218
|
meta: { mutating: true, idempotency_key: idempotencyKey }
|
|
14098
14219
|
});
|
|
14099
14220
|
} finally {
|
|
@@ -46,6 +46,8 @@ For every app UI create/edit task, read both [Design](references/design.md) and
|
|
|
46
46
|
no-deploy, and explicit-consent requirements. Store publication is separate.
|
|
47
47
|
- Preserve the exact app identity, account/team scope, permissions, and user data.
|
|
48
48
|
Reconcile an uncertain release instead of blindly retrying it.
|
|
49
|
+
- Before Store submission, complete the [publication privacy and portability gate](references/release.md#publication-privacy-and-portability-gate).
|
|
50
|
+
Public examples must be fictional; never publish the owner's workspace data.
|
|
49
51
|
- Use SDK hooks and declared tools. Let Notis own its sidebar, search, runtime,
|
|
50
52
|
and rendering boundary; do not query host DOM or recreate that chrome in the app.
|
|
51
53
|
|
|
@@ -7,8 +7,11 @@ All Notis apps are built using the Notis CLI, either locally in a repo workspace
|
|
|
7
7
|
- the portal renders it as a React component inside the portal's React tree
|
|
8
8
|
|
|
9
9
|
Use standard React pages in `app/`, not Next.js or a custom server. The host
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
uses one React/Shadow DOM runtime for authored apps, Store installations,
|
|
11
|
+
duplicates and SDK reports. Do not create your own iframe, query Portal-owned
|
|
12
|
+
DOM, or install a window-global runtime. Shadow DOM isolates styles, not
|
|
13
|
+
JavaScript or credentials: loaded bundles share the Portal browser context.
|
|
14
|
+
The host owns
|
|
12
15
|
theme injection and authentication. App code uses SDK hooks and the final tool
|
|
13
16
|
names discovered through the CLI, declared in `notis.config.ts` and enforced by
|
|
14
17
|
the backend. Runtime permissions stay least-authority; releasing an app does not
|
|
@@ -65,6 +65,50 @@ not required for an ordinary Workspace update.
|
|
|
65
65
|
|
|
66
66
|
## Special cases — read only when relevant
|
|
67
67
|
|
|
68
|
+
### Publication privacy and portability gate
|
|
69
|
+
|
|
70
|
+
Before submitting or updating a Store listing:
|
|
71
|
+
|
|
72
|
+
1. Inventory the exact public source archive, listing text/media, database schemas,
|
|
73
|
+
starter rows, bundled skills (including scripts/references), and automation
|
|
74
|
+
prompts/configuration. Review their actual contents, not just filenames or
|
|
75
|
+
a passing secret scan. Exclude personal records, transcripts, health/journal
|
|
76
|
+
history, customer details, private repository/account identifiers, credentials,
|
|
77
|
+
local paths, run logs, and private links. Do not merely replace a person's name
|
|
78
|
+
in otherwise real data. Rebuild examples from wholly fictional scenarios.
|
|
79
|
+
2. Keep live owner databases structure-only (`seedDocuments` absent or false).
|
|
80
|
+
Opting in seeds the database's live rows, including folders: it is not a
|
|
81
|
+
fixture selector. Use fictional screenshot fixtures and an explicit, idempotent
|
|
82
|
+
onboarding demo-data option. If starter rows are needed in the install snapshot,
|
|
83
|
+
publish only from an isolated, verified fictional dataset; never replace or
|
|
84
|
+
delete the owner's real data to prepare a submission. When the user asks for
|
|
85
|
+
examples to come with the app, include them in that verified install snapshot:
|
|
86
|
+
screenshot fixtures or an optional onboarding seed step do not satisfy this.
|
|
87
|
+
3. Bundle the full dependency closure of every app/automation skill, including
|
|
88
|
+
referenced helpers and resources. Remove private account-specific defaults;
|
|
89
|
+
resolve the installer's databases, connections, repository, timezone and
|
|
90
|
+
delivery choices at runtime. Preserve existing owner's schedules and data.
|
|
91
|
+
4. Declare a source-owned onboarding skill. It must work through available MCP
|
|
92
|
+
tools or the Notis CLI in any agent harness, without requiring Notis Manager,
|
|
93
|
+
vendor-specific delegation tools, hidden local files, or publisher access.
|
|
94
|
+
Discover tools and inspect schemas before calls; reconcile existing resources
|
|
95
|
+
before creating them. Obtain installer choices before enabling automation or
|
|
96
|
+
external actions. Installing examples must not activate external deliveries.
|
|
97
|
+
5. Test onboarding as an independent harness-native proof agent using an account
|
|
98
|
+
isolated from the publisher, then rerun to prove no duplicates. Exercise each
|
|
99
|
+
route and its interactions with fictional data, including empty/error states.
|
|
100
|
+
Record exact identities, versions, results and run-created resource cleanup;
|
|
101
|
+
never use owner records as writable test fixtures.
|
|
102
|
+
For bundled starter-data claims, install the actual published listing into an
|
|
103
|
+
empty test account and read back its rows before onboarding or any manual data
|
|
104
|
+
writes. Confirm the installed listing version and remapped relations; do not
|
|
105
|
+
substitute an editable-source deployment for this Store-install test.
|
|
106
|
+
6. Inspect the final submitted snapshot and media after packaging. Record the
|
|
107
|
+
privacy audit and verification against that exact source version. Any unknown
|
|
108
|
+
provenance, missing dependency, untested onboarding or suspected personal data
|
|
109
|
+
blocks submission until resolved. Never equate submission with review approval
|
|
110
|
+
or Store publication.
|
|
111
|
+
|
|
68
112
|
### Unreleased container or stale checkout
|
|
69
113
|
|
|
70
114
|
An unreleased container has no source to pull. Recover its original local source,
|
package/dist/sdk/src/runtime.ts
CHANGED
|
@@ -368,6 +368,8 @@ export interface RuntimeResource {
|
|
|
368
368
|
kind: 'app' | 'report';
|
|
369
369
|
/** Host-provided adoption eligibility, never report-authored authority. */
|
|
370
370
|
analytics_eligible?: boolean;
|
|
371
|
+
/** Host-derived report family for adoption analytics, e.g. skill review reports. */
|
|
372
|
+
report_kind?: 'skill_review' | 'custom';
|
|
371
373
|
id: string;
|
|
372
374
|
revision: number;
|
|
373
375
|
name?: string;
|
package/package.json
CHANGED
|
@@ -935,6 +935,7 @@ async function appsScreenshotHandler(ctx) {
|
|
|
935
935
|
sessionName: captureSessionName,
|
|
936
936
|
screenshotPath: browserScreenshotPath,
|
|
937
937
|
focusSelector: ctx.options.raw ? null : focus,
|
|
938
|
+
frameContent: !ctx.options.raw,
|
|
938
939
|
width,
|
|
939
940
|
height,
|
|
940
941
|
timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 15_000,
|
|
@@ -949,7 +950,7 @@ async function appsScreenshotHandler(ctx) {
|
|
|
949
950
|
height,
|
|
950
951
|
accent: appConfig.accent,
|
|
951
952
|
seed: appConfig.name || manifest.app?.name || appSlug,
|
|
952
|
-
focused: Boolean(
|
|
953
|
+
focused: Boolean(result.framing?.focus_selector),
|
|
953
954
|
theme: theme || 'light',
|
|
954
955
|
});
|
|
955
956
|
} catch (error) {
|
|
@@ -1256,15 +1257,22 @@ async function appsDeployHandler(ctx) {
|
|
|
1256
1257
|
try { release.close(); }
|
|
1257
1258
|
catch { warnings.push('The app was updated, but the temporary release directory needs local cleanup.'); }
|
|
1258
1259
|
|
|
1260
|
+
// One review report per skill this release changed; share the link when the user should review it.
|
|
1261
|
+
const skillReviews = (Array.isArray(result.payload.skill_reviews) ? result.payload.skill_reviews : [])
|
|
1262
|
+
.filter((review) => review && typeof review.review_url === 'string');
|
|
1259
1263
|
return ctx.output.emitSuccess({
|
|
1260
1264
|
command: ctx.spec.command_path.join(' '),
|
|
1261
1265
|
data: {
|
|
1262
1266
|
app_id: appId,
|
|
1263
1267
|
version: deployedVersion,
|
|
1264
1268
|
idempotency_key: idempotencyKey,
|
|
1269
|
+
...(skillReviews.length ? { skill_reviews: skillReviews } : {}),
|
|
1265
1270
|
},
|
|
1266
1271
|
warnings,
|
|
1267
|
-
humanSummary:
|
|
1272
|
+
humanSummary: [
|
|
1273
|
+
`Deployed to app ${appId} (version ${deployedVersion})`,
|
|
1274
|
+
...skillReviews.map((review) => `Review ${review.skill} changes: ${review.review_url}`),
|
|
1275
|
+
].join('\n'),
|
|
1268
1276
|
meta: { mutating: true, idempotency_key: idempotencyKey },
|
|
1269
1277
|
});
|
|
1270
1278
|
} finally {
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
-
|
|
5
|
-
export const HIDE_HARNESS_STATUS_SCRIPT =
|
|
6
|
-
"(() => { const el = document.getElementById('harness-status'); if (el) el.style.opacity = '0'; return true; })()";
|
|
4
|
+
import { contentCapturePlan, MEASURE_SCREENSHOT_CONTENT_SCRIPT, SAFE_VIEWPORT_HEIGHT_SCRIPT } from './screenshot-framing.js';
|
|
7
5
|
|
|
8
6
|
/**
|
|
9
|
-
* Prepare the mounted harness page for a listing capture: remove the
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* without artificial whitespace.
|
|
7
|
+
* Prepare the mounted harness page for a listing capture: remove the 100vh
|
|
8
|
+
* min-height the harness adds around the app so content height can be
|
|
9
|
+
* measured and framed without artificial whitespace.
|
|
13
10
|
*/
|
|
14
11
|
const PREPARE_CAPTURE_SCRIPT =
|
|
15
|
-
"(() => { const
|
|
16
|
-
" const r = document.getElementById('root'); if (r) { r.style.paddingTop = '0'; r.style.minHeight = '0'; }" +
|
|
12
|
+
"(() => { const r = document.getElementById('root'); if (r) r.style.minHeight = '0';" +
|
|
17
13
|
" document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
|
|
18
14
|
|
|
19
15
|
const FONTS_STATUS_SCRIPT =
|
|
@@ -322,18 +318,18 @@ export async function runHarnessRoute({
|
|
|
322
318
|
|
|
323
319
|
/**
|
|
324
320
|
* Open a harness route, wait for it to mount, then capture a PNG screenshot of
|
|
325
|
-
* the rendered app.
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* fixed output frame. Used by `notis apps screenshot`.
|
|
321
|
+
* the rendered app. Raw captures retain exact requested pixel dimensions.
|
|
322
|
+
* Framed captures can select the complete bounded app or a width-fitted first
|
|
323
|
+
* screen; the Store compositor normalizes them to the final listing size.
|
|
324
|
+
* Device scaling keeps text sharp at that size without changing app styles.
|
|
325
|
+
* Used by `notis apps screenshot`.
|
|
331
326
|
*/
|
|
332
327
|
export async function captureHarnessScreenshot({
|
|
333
328
|
url,
|
|
334
329
|
sessionName,
|
|
335
330
|
screenshotPath,
|
|
336
331
|
focusSelector = null,
|
|
332
|
+
frameContent = false,
|
|
337
333
|
width = 2000,
|
|
338
334
|
height = 1250,
|
|
339
335
|
timeoutMs = 15_000,
|
|
@@ -433,13 +429,50 @@ export async function captureHarnessScreenshot({
|
|
|
433
429
|
|
|
434
430
|
// Let the route settle (async data, resize transitions) before the capture.
|
|
435
431
|
await delay(500);
|
|
432
|
+
|
|
433
|
+
let contentBounds = null;
|
|
434
|
+
if (frameContent && !focusSelector) {
|
|
435
|
+
const measured = await runAgentBrowser(
|
|
436
|
+
['--session', sessionName, '--json', 'eval', MEASURE_SCREENSHOT_CONTENT_SCRIPT],
|
|
437
|
+
{ timeoutMs: 5000 },
|
|
438
|
+
);
|
|
439
|
+
if (measured.exitCode === 0) {
|
|
440
|
+
try {
|
|
441
|
+
const bounds = parseHarnessFromEval(measured.stdout);
|
|
442
|
+
if (bounds?.selector === '[data-notis-screenshot-content]'
|
|
443
|
+
&& Number.isFinite(bounds.width) && bounds.width > 0
|
|
444
|
+
&& Number.isFinite(bounds.height) && bounds.height > 0) {
|
|
445
|
+
contentBounds = bounds;
|
|
446
|
+
}
|
|
447
|
+
} catch { /* Keep the complete viewport if layout measurement is unavailable. */ }
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
let captureFocus = focusSelector || contentBounds?.selector || null;
|
|
451
|
+
if (contentBounds && scaledViewport) {
|
|
452
|
+
const plan = contentCapturePlan(contentBounds, framing || initial, width, height);
|
|
453
|
+
if (await setViewport(sessionName, plan.width, plan.height, plan.scale)) {
|
|
454
|
+
await delay(300);
|
|
455
|
+
if (!plan.focus) {
|
|
456
|
+
const safeHeight = await evalNumber(sessionName, SAFE_VIEWPORT_HEIGHT_SCRIPT, plan.height);
|
|
457
|
+
if (safeHeight < plan.height && await setViewport(sessionName, plan.width, safeHeight, plan.scale)) {
|
|
458
|
+
plan.height = safeHeight;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
captureFocus = plan.focus ? contentBounds.selector : null;
|
|
462
|
+
framing = {
|
|
463
|
+
...(framing || {}), ...plan, content_bounds: contentBounds,
|
|
464
|
+
capture_mode: plan.focus ? 'content' : 'width-fit-viewport',
|
|
465
|
+
};
|
|
466
|
+
await delay(300);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
436
469
|
const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
|
|
437
470
|
const design = Array.isArray(designResult) ? designResult : [];
|
|
438
471
|
|
|
439
472
|
mkdirSync(dirname(screenshotPath), { recursive: true });
|
|
440
473
|
const screenshotArgs = ['--session', sessionName, 'screenshot'];
|
|
441
|
-
if (
|
|
442
|
-
screenshotArgs.push(
|
|
474
|
+
if (captureFocus) {
|
|
475
|
+
screenshotArgs.push(captureFocus);
|
|
443
476
|
}
|
|
444
477
|
screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
|
|
445
478
|
const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
|
|
@@ -451,7 +484,13 @@ export async function captureHarnessScreenshot({
|
|
|
451
484
|
// capture came out wrong (e.g. the browser ignored the scale factor),
|
|
452
485
|
// recapture once at a plain fixed viewport.
|
|
453
486
|
const dimensions = pngDimensions(screenshotPath);
|
|
454
|
-
|
|
487
|
+
const expectedDimensions = framing?.capture_mode === 'width-fit-viewport'
|
|
488
|
+
? { width: Math.round(framing.width * framing.scale), height: Math.round(framing.height * framing.scale) }
|
|
489
|
+
: { width, height };
|
|
490
|
+
// Fractional browser scaling can round one pixel either way. Framed output
|
|
491
|
+
// is normalized by the compositor; raw captures still require exact pixels.
|
|
492
|
+
const tolerance = frameContent ? 1 : 0;
|
|
493
|
+
if (!captureFocus && framing && (!dimensions || Math.abs(dimensions.width - expectedDimensions.width) > tolerance || Math.abs(dimensions.height - expectedDimensions.height) > tolerance)) {
|
|
455
494
|
await setViewport(sessionName, width, height);
|
|
456
495
|
await delay(300);
|
|
457
496
|
const retry = await runAgentBrowser(
|
|
@@ -464,10 +503,11 @@ export async function captureHarnessScreenshot({
|
|
|
464
503
|
framing = null;
|
|
465
504
|
}
|
|
466
505
|
|
|
467
|
-
if (
|
|
506
|
+
if (captureFocus) {
|
|
468
507
|
framing = {
|
|
469
508
|
...(framing || {}),
|
|
470
|
-
focus_selector:
|
|
509
|
+
focus_selector: captureFocus,
|
|
510
|
+
content_bounds: contentBounds,
|
|
471
511
|
source_dimensions: dimensions,
|
|
472
512
|
};
|
|
473
513
|
}
|
|
@@ -538,7 +578,7 @@ export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
|
|
|
538
578
|
};
|
|
539
579
|
const all = Array.from(root.querySelectorAll('*'));
|
|
540
580
|
for (const el of all) {
|
|
541
|
-
if (
|
|
581
|
+
if (!visible(el) || isControl(el)) continue;
|
|
542
582
|
if (isBox(el)) {
|
|
543
583
|
let p = el.parentElement; let depth = 0;
|
|
544
584
|
while (p && p !== root && depth < 6) { if (!isControl(p) && isBox(p)) { push('nested_border_box', el); break; } p = p.parentElement; depth += 1; }
|
|
@@ -555,7 +595,7 @@ export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
|
|
|
555
595
|
const text = (node.textContent || '').trim();
|
|
556
596
|
if (!text) continue;
|
|
557
597
|
const el = node.parentElement;
|
|
558
|
-
if (!el ||
|
|
598
|
+
if (!el || !visible(el)) continue;
|
|
559
599
|
const size = parseFloat(getComputedStyle(el).fontSize);
|
|
560
600
|
if (size > 0 && size < 12) push('text_below_12px', el, { font_size: size });
|
|
561
601
|
if (/^Loading(\\.{3}|\\u2026)?(\\s|$)/.test(text)) push('loading_placeholder_after_mount', el);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Runs only in the capture harness, never in an installed app. */
|
|
2
|
+
export function measureScreenshotContent() {
|
|
3
|
+
const root = document.getElementById('root');
|
|
4
|
+
if (!root) return null;
|
|
5
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
6
|
+
const visible = (element) => {
|
|
7
|
+
const rect = element.getBoundingClientRect();
|
|
8
|
+
const style = getComputedStyle(element);
|
|
9
|
+
return rect.width > 0 && rect.height > 0
|
|
10
|
+
&& style.display !== 'none' && style.visibility !== 'hidden';
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Follow layout wrappers only while they contain the entire visible app.
|
|
14
|
+
// Never select one column, card, or control out of a multi-pane layout.
|
|
15
|
+
let node = root;
|
|
16
|
+
let content = null;
|
|
17
|
+
while (node) {
|
|
18
|
+
const hasOwnText = Array.from(node.childNodes).some(
|
|
19
|
+
(child) => child.nodeType === 3 && child.textContent.trim(),
|
|
20
|
+
);
|
|
21
|
+
const children = Array.from(node.children).filter(visible);
|
|
22
|
+
if (hasOwnText || children.length !== 1) break;
|
|
23
|
+
node = children[0];
|
|
24
|
+
const rect = node.getBoundingClientRect();
|
|
25
|
+
if (['DIV', 'MAIN', 'SECTION', 'ARTICLE', 'FORM'].includes(node.tagName)
|
|
26
|
+
&& rect.width >= 320 && rect.height >= 160
|
|
27
|
+
&& rect.width < viewportWidth * 0.9) {
|
|
28
|
+
content = node;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!content) return null;
|
|
32
|
+
content.setAttribute('data-notis-screenshot-content', '');
|
|
33
|
+
const rect = content.getBoundingClientRect();
|
|
34
|
+
return {
|
|
35
|
+
selector: '[data-notis-screenshot-content]',
|
|
36
|
+
width: rect.width,
|
|
37
|
+
height: rect.height,
|
|
38
|
+
viewport_width: viewportWidth,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const MEASURE_SCREENSHOT_CONTENT_SCRIPT = `(${measureScreenshotContent.toString()})()`;
|
|
43
|
+
|
|
44
|
+
/** Keep long pages readable, and rasterize bounded views at their final width. */
|
|
45
|
+
export function contentCapturePlan(bounds, viewport, outputWidth, outputHeight) {
|
|
46
|
+
const aspect = outputWidth / outputHeight;
|
|
47
|
+
if (bounds.width / bounds.height < aspect * 0.75) {
|
|
48
|
+
const width = Math.max(768, Math.ceil((bounds.width + 32) / 8) * 8);
|
|
49
|
+
return { focus: false, width, height: Math.round(width / aspect), scale: outputWidth / width };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
focus: true,
|
|
53
|
+
width: viewport.width,
|
|
54
|
+
height: viewport.height,
|
|
55
|
+
scale: Math.min(4, outputWidth / bounds.width),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function safeViewportHeight(rectangles, height) {
|
|
60
|
+
const crossing = rectangles.filter((rect) => rect.top < height && rect.bottom > height
|
|
61
|
+
&& rect.top >= height * 0.85);
|
|
62
|
+
return crossing.length
|
|
63
|
+
? Math.max(Math.ceil(height * 0.85), Math.floor(Math.min(...crossing.map((rect) => rect.top)) - 8))
|
|
64
|
+
: height;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// End a long-page viewport in whitespace rather than through a text line or
|
|
68
|
+
// control. Only geometry leaves the browser; no page text is returned.
|
|
69
|
+
export const SAFE_VIEWPORT_HEIGHT_SCRIPT = `(() => {
|
|
70
|
+
const root = document.getElementById('root');
|
|
71
|
+
if (!root) return innerHeight;
|
|
72
|
+
const rects = [];
|
|
73
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
74
|
+
let node;
|
|
75
|
+
while ((node = walker.nextNode())) {
|
|
76
|
+
if (!node.textContent.trim() || getComputedStyle(node.parentElement).visibility !== 'visible') continue;
|
|
77
|
+
const range = document.createRange(); range.selectNodeContents(node);
|
|
78
|
+
for (const rect of range.getClientRects()) rects.push({ top: rect.top, bottom: rect.bottom });
|
|
79
|
+
}
|
|
80
|
+
for (const control of root.querySelectorAll('button,input,select,textarea')) {
|
|
81
|
+
const rect = control.getBoundingClientRect();
|
|
82
|
+
rects.push({ top: rect.top, bottom: rect.bottom });
|
|
83
|
+
}
|
|
84
|
+
return (${safeViewportHeight.toString()})(rects, innerHeight);
|
|
85
|
+
})()`;
|
|
@@ -38,15 +38,17 @@ export function resolveStoreScreenshotAccent(accent, seed = 'notis-app') {
|
|
|
38
38
|
return ACCENT_NAMES[stableHash(seed) % ACCENT_NAMES.length];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function storeScreenshotLayout(width = 2000, height = 1250) {
|
|
41
|
+
export function storeScreenshotLayout(width = 2000, height = 1250, contentAspect = STORE_SCREENSHOT_ASPECT) {
|
|
42
|
+
const aspect = Number.isFinite(contentAspect) && contentAspect > 0
|
|
43
|
+
? contentAspect : STORE_SCREENSHOT_ASPECT;
|
|
42
44
|
const horizontalInset = Math.max(48, Math.round(width * 0.06));
|
|
43
45
|
const verticalInset = Math.max(40, Math.round(height * 0.06));
|
|
44
46
|
let cardWidth = width - horizontalInset * 2;
|
|
45
|
-
let cardHeight = Math.round(cardWidth /
|
|
47
|
+
let cardHeight = Math.round(cardWidth / aspect);
|
|
46
48
|
const maximumCardHeight = height - verticalInset * 2;
|
|
47
49
|
if (cardHeight > maximumCardHeight) {
|
|
48
50
|
cardHeight = maximumCardHeight;
|
|
49
|
-
cardWidth = Math.round(cardHeight *
|
|
51
|
+
cardWidth = Math.round(cardHeight * aspect);
|
|
50
52
|
}
|
|
51
53
|
const cardLeft = Math.round((width - cardWidth) / 2);
|
|
52
54
|
const cardTop = Math.round((height - cardHeight) / 2) - Math.round(height * 0.008);
|
|
@@ -80,7 +82,7 @@ function roundedRectSvg(width, height, radius, { fill = '#ffffff', stroke = null
|
|
|
80
82
|
|
|
81
83
|
/**
|
|
82
84
|
* Composite a truthful browser capture into the deterministic Store frame.
|
|
83
|
-
*
|
|
85
|
+
* Fit the complete source pixels; never crop controls or redraw the app UI.
|
|
84
86
|
*/
|
|
85
87
|
export async function composeStoreScreenshot({
|
|
86
88
|
inputPath,
|
|
@@ -94,13 +96,14 @@ export async function composeStoreScreenshot({
|
|
|
94
96
|
}) {
|
|
95
97
|
const sharp = await loadSharp();
|
|
96
98
|
const resolvedTheme = theme === 'dark' ? 'dark' : 'light';
|
|
97
|
-
const
|
|
99
|
+
const source = await sharp(inputPath).metadata();
|
|
100
|
+
const layout = storeScreenshotLayout(width, height, source.width / source.height);
|
|
98
101
|
const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
|
|
99
102
|
|
|
100
103
|
const roundedCapture = await sharp(inputPath)
|
|
101
104
|
.resize(layout.cardWidth, layout.cardHeight, {
|
|
102
|
-
fit: '
|
|
103
|
-
|
|
105
|
+
fit: 'contain',
|
|
106
|
+
background: resolvedTheme === 'dark' ? '#09090b' : '#ffffff',
|
|
104
107
|
})
|
|
105
108
|
.composite([{
|
|
106
109
|
input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),
|
|
@@ -93,12 +93,10 @@
|
|
|
93
93
|
color: hsl(var(--foreground));
|
|
94
94
|
-webkit-font-smoothing: antialiased;
|
|
95
95
|
}
|
|
96
|
-
#
|
|
97
|
-
#root { padding-top: 24px; min-height: 100vh; }
|
|
96
|
+
#root { min-height: 100vh; }
|
|
98
97
|
</style>
|
|
99
98
|
</head>
|
|
100
99
|
<body>
|
|
101
|
-
<div id="harness-status">harness: booting</div>
|
|
102
100
|
<div id="root" data-notis-app-root></div>
|
|
103
101
|
<script type="module">
|
|
104
102
|
const routeExport = {{ROUTE_EXPORT}};
|
|
@@ -134,10 +132,9 @@
|
|
|
134
132
|
return Object.prototype.hasOwnProperty.call(tools, name) ? tools[name] : undefined;
|
|
135
133
|
}
|
|
136
134
|
|
|
137
|
-
|
|
138
|
-
const setStatus = (msg,
|
|
139
|
-
|
|
140
|
-
status.style.color = color;
|
|
135
|
+
// Boot progress goes to the console so the rendered page stays identical to the app.
|
|
136
|
+
const setStatus = (msg, failed = false) => {
|
|
137
|
+
(failed ? console.error : console.info)(`[notis harness] ${msg}`);
|
|
141
138
|
};
|
|
142
139
|
|
|
143
140
|
window.__harness = {
|
|
@@ -416,13 +413,13 @@
|
|
|
416
413
|
window.__harness.mounted = true;
|
|
417
414
|
const errors = window.__harness.errors.length;
|
|
418
415
|
const calls = window.__harness.runtimeCalls.length;
|
|
419
|
-
setStatus(`mounted (errors=${errors}, runtimeCalls=${calls})`, errors
|
|
416
|
+
setStatus(`mounted (errors=${errors}, runtimeCalls=${calls})`, errors > 0);
|
|
420
417
|
} catch (error) {
|
|
421
418
|
window.__harness.errors.push({
|
|
422
419
|
type: 'scenario',
|
|
423
420
|
message: error && error.message ? error.message : String(error),
|
|
424
421
|
});
|
|
425
|
-
setStatus(`scenario failed: ${error && error.message ? error.message : String(error)}`,
|
|
422
|
+
setStatus(`scenario failed: ${error && error.message ? error.message : String(error)}`, true);
|
|
426
423
|
}
|
|
427
424
|
}));
|
|
428
425
|
} catch (error) {
|
|
@@ -431,7 +428,7 @@
|
|
|
431
428
|
message: error && error.message ? error.message : String(error),
|
|
432
429
|
stack: error && error.stack,
|
|
433
430
|
});
|
|
434
|
-
setStatus(`boot failed: ${error && error.message ? error.message : String(error)}`,
|
|
431
|
+
setStatus(`boot failed: ${error && error.message ? error.message : String(error)}`, true);
|
|
435
432
|
}
|
|
436
433
|
</script>
|
|
437
434
|
</body>
|