@mearl/preset 3.0.0-beta.4 → 3.0.0-beta.7
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 +45 -2
- package/dist/browserControls.d.ts +29 -0
- package/dist/browserControls.js +54 -0
- package/dist/browserPreset.js +8 -3
- package/dist/browserViews.d.ts +1 -1
- package/dist/browserViews.js +5 -5
- package/dist/bundle.d.ts +4 -1
- package/dist/bundle.js +24 -5
- package/dist/bundleStore.js +80 -75
- package/dist/bundles.d.ts +7 -1
- package/dist/bundles.js +4 -1
- package/dist/{nodeSnapshot.d.ts → capabilitySnapshot.d.ts} +11 -11
- package/dist/{nodeSnapshot.js → capabilitySnapshot.js} +36 -35
- package/dist/extensionContributions.d.ts +29 -0
- package/dist/extensionContributions.js +57 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/installation.d.ts +5 -1
- package/dist/installation.js +19 -6
- package/dist/manifest.js +5 -5
- package/dist/networkRoutes.d.ts +12 -0
- package/dist/networkRoutes.js +32 -0
- package/dist/prepare.d.ts +6 -0
- package/dist/prepare.js +81 -16
- package/dist/preset.js +3 -1
- package/dist/requestUi.d.ts +3 -1
- package/dist/requestUi.js +2 -2
- package/dist/resolve.js +4 -0
- package/dist/toolboxCatalogs.d.ts +8 -0
- package/dist/toolboxCatalogs.js +34 -0
- package/dist/types.d.ts +2 -1
- package/installation-site.json +4 -0
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @mearl/preset
|
|
2
2
|
|
|
3
|
+
3.x 的声明、配置、能力目录及对象存储设计见 [能力包模型](../../docs/capability-model.md)。包支持的位置从贡献推导,Preset 的可选 `targets` 仅选择启用子集;操作和登录定义归包,配置覆盖归 Preset 与用户。
|
|
4
|
+
|
|
3
5
|
浏览器包的准备与本地存储见 [能力包](../../docs/browser-bundles.md),整套原子激活与 App 实现映射见 [浏览器 Preset](../../docs/browser-presets.md)。这两类能力通过 `@mearl/preset/bundles` 导出。
|
|
4
6
|
|
|
5
7
|
浏览器和 Node 共用一次继承解析及资源下载,见 [整套资源准备](../../docs/preset-preparation.md)。`preparePreset` 返回完整确切图与兼容的浏览器投影;Node 模块选择直接复用同批字节,准备过程不执行代码或安装外部环境。
|
|
@@ -35,7 +37,7 @@ Node 模块快照与宿主加载合同见 [Node 能力包](../../docs/node-capab
|
|
|
35
37
|
"extends": { "source": "../base/preset.json", "version": "^1" },
|
|
36
38
|
"requires": { "mearlApi": "^1" },
|
|
37
39
|
"packages": {
|
|
38
|
-
"capture": { "source": "./capture/package.json", "
|
|
40
|
+
"capture": { "source": "./capture/package.json", "targets": ["browser"] },
|
|
39
41
|
"unused": { "enabled": false }
|
|
40
42
|
},
|
|
41
43
|
"skills": {},
|
|
@@ -56,6 +58,47 @@ Node 模块快照与宿主加载合同见 [Node 能力包](../../docs/node-capab
|
|
|
56
58
|
- 结果保留父级到子级的确切版本链和各组件最近声明来源,加载失败不会生成部分成功的结果。
|
|
57
59
|
- 拒绝未知字段、非 JSON 配置和原型污染键。清单总量不超过 256 Ki UTF-16 code units,defaults 最大嵌套 32 层。
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
本包负责通用清单解析、校验和资源图,不执行安装或展示 UI。完整安装器、preset 锁文件持久化、权限、注册与激活由浏览器宿主及 setup 提供,见 [安装宿主](../../docs/preset-installation.md);能力包本地存储负责已校验快照的事务替换。
|
|
60
62
|
|
|
61
63
|
Skill manager 的可选 `readSources(scope, cwd)` 返回该管理器核验过的来源收据,结构与 setup 的 `TrackedSkillSource` 一致。CLI 与 Native 安装状态共用该读取接口;管理器负责持久化、校验和失效自身补充记录,公共宿主继续核对来源、ref、清单身份与托管归属。未提供时读取标准 Skills 锁文件。
|
|
64
|
+
|
|
65
|
+
## 工具目录
|
|
66
|
+
|
|
67
|
+
Preset 可通过 `defaults.extension.toolCatalogs` 注册额外的可安装工具目录:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"defaults": {
|
|
72
|
+
"extension": {
|
|
73
|
+
"toolCatalogs": {
|
|
74
|
+
"team": {
|
|
75
|
+
"url": "https://tools.example.com/toolbox.json",
|
|
76
|
+
"label": "Team"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
目录使用绝对 HTTPS URL(本机开发允许 loopback HTTP),最多 16 个。使用稳定的目录 ID,子 preset 可只覆盖 `url` 或 `label`,或设置 `enabled: false` 禁用父级目录。官方目录始终保留且优先,不能使用保留 ID `official`。需要跨域访问时在 preset 的 `permissions.origins` 声明相应域名,由现有授权流程取得权限。
|
|
85
|
+
|
|
86
|
+
目录 JSON 与官方目录使用同一结构,最多 256 项、256 KiB:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"tools": [{
|
|
91
|
+
"id": "plugin:team-tool",
|
|
92
|
+
"plugin": "team-tool",
|
|
93
|
+
"repository": "example/skills",
|
|
94
|
+
"skill": "team-tool",
|
|
95
|
+
"title": "Team tool",
|
|
96
|
+
"description": "A tool for the team",
|
|
97
|
+
"icon": "wrench"
|
|
98
|
+
}]
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`repository` 也支持 HTTPS/SSH Git 来源和固定 ref(例如 `git@code.example.com:team/skills.git#release/v3`),安装时原样交给 preset 选择的 Skills 管理器。清单不接受 shell 参数、凭据或本地文件路径,也不执行其中的代码;列出工具不会自动安装。
|
|
103
|
+
|
|
104
|
+
各来源并发读取、独立缓存,重试时刷新当前目录;失败时保留该来源已有缓存,其他来源仍可使用。相同插件 ID 与安装来源去重;同 ID 指向不同仓库或 Skill 时保留优先目录并提示冲突。较低优先目录的安装按钮会等待前面的目录完成检查,避免加载顺序改变安装目标。更换或禁用目录后,旧来源和迟到响应不会留在当前列表。
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type PreparedBundle } from './bundle.js';
|
|
2
|
+
import { type BrowserUiText } from './requestUi.js';
|
|
3
|
+
import { type NetworkRoutePlan } from './networkRoutes.js';
|
|
4
|
+
export interface BrowserSegmentedControl {
|
|
5
|
+
schemaVersion: 1;
|
|
6
|
+
id: string;
|
|
7
|
+
kind: 'segmented';
|
|
8
|
+
label: BrowserUiText;
|
|
9
|
+
description: BrowserUiText;
|
|
10
|
+
defaultValue: string;
|
|
11
|
+
aliases?: string[];
|
|
12
|
+
messages: {
|
|
13
|
+
changed: BrowserUiText;
|
|
14
|
+
failed: BrowserUiText;
|
|
15
|
+
};
|
|
16
|
+
options: Array<{
|
|
17
|
+
value: string;
|
|
18
|
+
label: BrowserUiText;
|
|
19
|
+
networkRoutes: NetworkRoutePlan;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
export interface SelectedBrowserControl {
|
|
23
|
+
bundleId: string;
|
|
24
|
+
revision: string;
|
|
25
|
+
definition: BrowserSegmentedControl;
|
|
26
|
+
}
|
|
27
|
+
export declare function validateBrowserControl(input: unknown): BrowserSegmentedControl;
|
|
28
|
+
/** The network-route surface has one owner, so competing packages cannot silently overwrite it. */
|
|
29
|
+
export declare function selectBrowserControl(bundles: PreparedBundle[]): SelectedBrowserControl | undefined;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { BundleError, readBundleJson } from './bundle.js';
|
|
2
|
+
import { validateBrowserUiText } from './requestUi.js';
|
|
3
|
+
import { parseNetworkRoutePlan } from './networkRoutes.js';
|
|
4
|
+
const id = /^[a-z][a-zA-Z0-9_.-]{0,79}$/;
|
|
5
|
+
function fields(value, names) {
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !names.includes(key)))
|
|
7
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid browser control');
|
|
8
|
+
}
|
|
9
|
+
export function validateBrowserControl(input) {
|
|
10
|
+
fields(input, ['schemaVersion', 'id', 'kind', 'label', 'description', 'defaultValue', 'aliases', 'messages', 'options']);
|
|
11
|
+
if (input.schemaVersion !== 1 || input.kind !== 'segmented' || typeof input.id !== 'string' || !id.test(input.id) || typeof input.defaultValue !== 'string' || !id.test(input.defaultValue))
|
|
12
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid segmented control');
|
|
13
|
+
validateBrowserUiText(input.label);
|
|
14
|
+
validateBrowserUiText(input.description);
|
|
15
|
+
fields(input.messages, ['changed', 'failed']);
|
|
16
|
+
validateBrowserUiText(input.messages.changed);
|
|
17
|
+
validateBrowserUiText(input.messages.failed);
|
|
18
|
+
if (input.aliases !== undefined && (!Array.isArray(input.aliases) || input.aliases.length > 8 || new Set(input.aliases).size !== input.aliases.length || input.aliases.some(alias => typeof alias !== 'string' || !id.test(alias))))
|
|
19
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid browser control aliases');
|
|
20
|
+
if (!Array.isArray(input.options) || input.options.length < 2 || input.options.length > 8)
|
|
21
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid browser control options');
|
|
22
|
+
const values = new Set();
|
|
23
|
+
const options = input.options.map(option => {
|
|
24
|
+
fields(option, ['value', 'label', 'networkRoutes']);
|
|
25
|
+
if (typeof option.value !== 'string' || !id.test(option.value) || values.has(option.value))
|
|
26
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid browser control option');
|
|
27
|
+
values.add(option.value);
|
|
28
|
+
validateBrowserUiText(option.label);
|
|
29
|
+
try {
|
|
30
|
+
return { value: option.value, label: structuredClone(option.label), networkRoutes: parseNetworkRoutePlan(option.networkRoutes) };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid control network route plan');
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
if (!values.has(input.defaultValue) || options.find(option => option.value === input.defaultValue).networkRoutes.routes.length)
|
|
37
|
+
throw new BundleError('BUNDLE_INVALID', 'The default option must clear network routes');
|
|
38
|
+
if (new TextEncoder().encode(JSON.stringify(input)).byteLength > 65536)
|
|
39
|
+
throw new BundleError('BUNDLE_QUOTA', 'Browser control exceeds 64 KiB');
|
|
40
|
+
return { schemaVersion: 1, id: input.id, kind: 'segmented', label: structuredClone(input.label), description: structuredClone(input.description), defaultValue: input.defaultValue, messages: structuredClone(input.messages), options, ...(input.aliases ? { aliases: [...input.aliases] } : {}) };
|
|
41
|
+
}
|
|
42
|
+
/** The network-route surface has one owner, so competing packages cannot silently overwrite it. */
|
|
43
|
+
export function selectBrowserControl(bundles) {
|
|
44
|
+
let selected;
|
|
45
|
+
for (const bundle of bundles) {
|
|
46
|
+
const resource = bundle.manifest.contributions?.browserControls;
|
|
47
|
+
if (!resource)
|
|
48
|
+
continue;
|
|
49
|
+
if (selected)
|
|
50
|
+
throw new BundleError('BUNDLE_CONFLICT', 'Browser network control has multiple providers');
|
|
51
|
+
selected = { bundleId: bundle.manifest.id, revision: bundle.revision, definition: validateBrowserControl(readBundleJson(bundle, resource)) };
|
|
52
|
+
}
|
|
53
|
+
return selected;
|
|
54
|
+
}
|
package/dist/browserPreset.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
+
import { selectBrowserControl } from './browserControls.js';
|
|
1
2
|
import { BundleError, MAX_BROWSER_STORAGE_BYTES, verifyBundle } from './bundle.js';
|
|
2
3
|
import { satisfies } from 'semver';
|
|
3
|
-
import { preparePresetPackages, presetPackages, presetSnapshotRevision, verifyPresetGraph } from './prepare.js';
|
|
4
|
+
import { preparePresetPackages, presetPackages, presetSnapshotRevision, verifyPresetGraph, verifyPackageTargets } from './prepare.js';
|
|
4
5
|
/** Core browser defaults also need synchronization when no browser package is declared. */
|
|
5
6
|
export function presetRequiresBrowser(preset) {
|
|
6
|
-
return Object.values(preset.packages).some(entry => entry.enabled !== false && entry.
|
|
7
|
+
return Object.values(preset.packages).some(entry => entry.enabled !== false && entry.targets?.includes('browser')) || Object.keys(preset.appProfiles).length > 0 || Object.keys(preset.defaults).some(key => key === 'simulation' || key === 'extension' || key === 'services');
|
|
7
8
|
}
|
|
8
9
|
/** Browser selection can be derived from the same accepted batch without another download. */
|
|
9
10
|
export async function browserPresetSnapshot(source, manifests, preset, bundles) {
|
|
10
11
|
const ids = new Set(presetPackages(preset, ['browser']).map(([id]) => id));
|
|
11
12
|
const selected = bundles.filter(bundle => ids.has(bundle.manifest.id));
|
|
12
|
-
|
|
13
|
+
selectBrowserControl(selected);
|
|
14
|
+
const packageTargets = Object.fromEntries(Object.entries(preset.packages).filter(([, entry]) => entry.enabled !== false).map(([id, entry]) => [id, entry.targets]));
|
|
15
|
+
const data = { source, manifests, bundleRevisions: Object.fromEntries(selected.map(bundle => [bundle.manifest.id, bundle.revision])), packageTargets };
|
|
13
16
|
return { snapshot: { ...data, revision: await presetSnapshotRevision(data) }, preset, bundles: selected };
|
|
14
17
|
}
|
|
15
18
|
export async function prepareBrowserPreset(source, options) {
|
|
@@ -48,5 +51,7 @@ export async function verifyPreparedBrowserPreset(value, mearlApiVersion) {
|
|
|
48
51
|
return graph.snapshot.bundleRevisions[bundle.manifest.id] !== bundle.revision || entry?.source !== bundle.source || (entry.version && !satisfies(bundle.manifest.version, entry.version));
|
|
49
52
|
}))
|
|
50
53
|
throw new BundleError('BUNDLE_INTEGRITY', 'Browser activation requires every exact package');
|
|
54
|
+
selectBrowserControl(bundles);
|
|
55
|
+
verifyPackageTargets(graph.preset, bundles);
|
|
51
56
|
return { ...graph, bundles };
|
|
52
57
|
}
|
package/dist/browserViews.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface BrowserViewDefinition {
|
|
|
16
16
|
entry: string;
|
|
17
17
|
actions: Record<string, BrowserViewAction>;
|
|
18
18
|
preferences?: Record<string, {
|
|
19
|
-
|
|
19
|
+
shared?: 'editor.highlight' | 'json.viewMode' | 'json.shortcuts' | 'code.shortcuts';
|
|
20
20
|
area?: 'local' | 'session';
|
|
21
21
|
}>;
|
|
22
22
|
transports?: Record<string, BrowserViewTransport>;
|
package/dist/browserViews.js
CHANGED
|
@@ -30,13 +30,13 @@ export function validateBrowserView(input) {
|
|
|
30
30
|
if (value.preferences !== undefined) {
|
|
31
31
|
const aliases = new Set();
|
|
32
32
|
for (const [, preference] of entries(value.preferences, 32)) {
|
|
33
|
-
fields(preference, ['
|
|
33
|
+
fields(preference, ['shared', 'area']);
|
|
34
34
|
if (preference.area !== undefined && preference.area !== 'local' && preference.area !== 'session')
|
|
35
35
|
invalid('Invalid browser view preference area');
|
|
36
|
-
if (preference.
|
|
37
|
-
if (typeof preference.
|
|
36
|
+
if (preference.shared !== undefined) {
|
|
37
|
+
if (typeof preference.shared !== 'string' || !['editor.highlight', 'json.viewMode', 'json.shortcuts', 'code.shortcuts'].includes(preference.shared) || aliases.has(preference.shared))
|
|
38
38
|
invalid('Invalid browser view preference alias');
|
|
39
|
-
aliases.add(preference.
|
|
39
|
+
aliases.add(preference.shared);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -200,5 +200,5 @@ export function buildBrowserViewPage(html) {
|
|
|
200
200
|
if (typeof html !== 'string' || !html.trim())
|
|
201
201
|
invalid('Browser view entry must not be empty');
|
|
202
202
|
const policy = "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
203
|
-
return `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${policy}"><script>(${installBrowserViewBridge.toString()})();</script>${html}
|
|
203
|
+
return `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${policy}"><script>(${installBrowserViewBridge.toString()})();</script>${html}<style data-mearl-view-surface>html,body{background:transparent!important}</style>`;
|
|
204
204
|
}
|
package/dist/bundle.d.ts
CHANGED
|
@@ -14,17 +14,20 @@ export interface BundleManifest {
|
|
|
14
14
|
requires?: {
|
|
15
15
|
mearlApi: string;
|
|
16
16
|
};
|
|
17
|
+
defaults?: import('./types.js').JsonObject;
|
|
17
18
|
resources: Record<string, {
|
|
18
19
|
sha256: string;
|
|
19
20
|
byteLength: number;
|
|
20
21
|
}>;
|
|
21
22
|
contributions?: {
|
|
22
|
-
appProfiles
|
|
23
|
+
appProfiles?: string[];
|
|
23
24
|
pageRuntimes?: Record<string, string>;
|
|
24
25
|
nodeRuntimes?: Record<string, CapabilityRuntimeDeclaration>;
|
|
25
26
|
browserRuntimes?: Record<string, CapabilityRuntimeDeclaration>;
|
|
27
|
+
extension?: string;
|
|
26
28
|
requestUi?: string;
|
|
27
29
|
browserViews?: Record<string, string>;
|
|
30
|
+
browserControls?: string;
|
|
28
31
|
};
|
|
29
32
|
}
|
|
30
33
|
export interface CapabilityRuntimeDeclaration {
|
package/dist/bundle.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { satisfies, valid, validRange } from 'semver';
|
|
2
2
|
import { clonePresetDefaults } from './manifest.js';
|
|
3
|
+
import { validateBrowserControl } from './browserControls.js';
|
|
3
4
|
export const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
|
|
4
5
|
export const MAX_BUNDLE_MANIFEST_BYTES = 64 * 1024;
|
|
5
6
|
export const MAX_BROWSER_STORAGE_BYTES = 64 * 1024 * 1024;
|
|
@@ -47,7 +48,9 @@ export function validateBundleManifest(value, mearlApiVersion) {
|
|
|
47
48
|
catch {
|
|
48
49
|
invalid('Expected a JSON manifest');
|
|
49
50
|
}
|
|
50
|
-
fields(json, ['schemaVersion', 'id', 'version', 'requires', 'resources', 'contributions']);
|
|
51
|
+
fields(json, ['schemaVersion', 'id', 'version', 'requires', 'defaults', 'resources', 'contributions']);
|
|
52
|
+
if (json.defaults !== undefined)
|
|
53
|
+
json.defaults = clonePresetDefaults(json.defaults);
|
|
51
54
|
if (json.schemaVersion !== 1 || typeof json.id !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/.test(json.id))
|
|
52
55
|
invalid('Invalid bundle identity');
|
|
53
56
|
if (typeof json.version !== 'string' || valid(json.version) !== json.version)
|
|
@@ -79,7 +82,17 @@ export function validateBundleManifest(value, mearlApiVersion) {
|
|
|
79
82
|
throw new BundleError('BUNDLE_QUOTA', 'Bundle resources exceed 16 MiB');
|
|
80
83
|
}
|
|
81
84
|
if (json.contributions !== undefined) {
|
|
82
|
-
fields(json.contributions, ['appProfiles', 'pageRuntimes', 'nodeRuntimes', 'browserRuntimes', 'requestUi', 'browserViews']);
|
|
85
|
+
fields(json.contributions, ['appProfiles', 'pageRuntimes', 'nodeRuntimes', 'browserRuntimes', 'extension', 'requestUi', 'browserViews', 'browserControls']);
|
|
86
|
+
const extension = json.contributions.extension;
|
|
87
|
+
if (extension !== undefined && (typeof extension !== 'string' || !Object.hasOwn(json.resources, extension)))
|
|
88
|
+
invalid('Extension contributions must reference a declared resource');
|
|
89
|
+
if (typeof extension === 'string' && json.resources[extension].byteLength > MAX_REQUEST_UI_BYTES)
|
|
90
|
+
throw new BundleError('BUNDLE_QUOTA', 'Extension contributions exceed 64 KiB');
|
|
91
|
+
const controls = json.contributions.browserControls;
|
|
92
|
+
if (controls !== undefined && (typeof controls !== 'string' || !Object.hasOwn(json.resources, controls)))
|
|
93
|
+
invalid('Browser controls must reference a declared resource');
|
|
94
|
+
if (typeof controls === 'string' && json.resources[controls].byteLength > MAX_REQUEST_UI_BYTES)
|
|
95
|
+
throw new BundleError('BUNDLE_QUOTA', 'Browser controls exceed 64 KiB');
|
|
83
96
|
if (json.contributions.requestUi !== undefined && (typeof json.contributions.requestUi !== 'string' || !Object.hasOwn(json.resources, json.contributions.requestUi)))
|
|
84
97
|
invalid('Request UI must reference a declared resource');
|
|
85
98
|
if (typeof json.contributions.requestUi === 'string' && json.resources[json.contributions.requestUi].byteLength > MAX_REQUEST_UI_BYTES)
|
|
@@ -93,7 +106,7 @@ export function validateBundleManifest(value, mearlApiVersion) {
|
|
|
93
106
|
throw new BundleError('BUNDLE_QUOTA', 'Browser view definition exceeds 64 KiB');
|
|
94
107
|
}
|
|
95
108
|
}
|
|
96
|
-
const profiles = json.contributions.appProfiles;
|
|
109
|
+
const profiles = json.contributions.appProfiles === undefined ? [] : json.contributions.appProfiles;
|
|
97
110
|
if (!Array.isArray(profiles) || profiles.some(name => typeof name !== 'string' || !Object.hasOwn(json.resources, name)) ||
|
|
98
111
|
new Set(profiles).size !== profiles.length)
|
|
99
112
|
invalid('App profiles must reference distinct declared resources');
|
|
@@ -188,7 +201,10 @@ export async function prepareBundle(source, options) {
|
|
|
188
201
|
resources[name] = resource;
|
|
189
202
|
}
|
|
190
203
|
options.signal?.throwIfAborted();
|
|
191
|
-
|
|
204
|
+
const prepared = { source, manifest, revision: await manifestRevision(source, manifest), resources };
|
|
205
|
+
if (manifest.contributions?.browserControls)
|
|
206
|
+
validateBrowserControl(readBundleJson(prepared, manifest.contributions.browserControls));
|
|
207
|
+
return prepared;
|
|
192
208
|
}
|
|
193
209
|
/** Revalidates persisted snapshots without network access and returns independent resource buffers. */
|
|
194
210
|
export async function verifyBundle(bundle, mearlApiVersion) {
|
|
@@ -215,5 +231,8 @@ export async function verifyBundle(bundle, mearlApiVersion) {
|
|
|
215
231
|
if (await bundleDigest(resources[name]) !== expected.sha256)
|
|
216
232
|
throw new BundleError('BUNDLE_INTEGRITY', `Stored resource integrity check failed: ${name}`);
|
|
217
233
|
}
|
|
218
|
-
|
|
234
|
+
const prepared = { source, manifest, revision, resources };
|
|
235
|
+
if (manifest.contributions?.browserControls)
|
|
236
|
+
validateBrowserControl(readBundleJson(prepared, manifest.contributions.browserControls));
|
|
237
|
+
return prepared;
|
|
219
238
|
}
|
package/dist/bundleStore.js
CHANGED
|
@@ -11,8 +11,8 @@ function installationMetadata(value) {
|
|
|
11
11
|
}
|
|
12
12
|
const STORE = 'bundles';
|
|
13
13
|
const PRESET = 'preset';
|
|
14
|
-
const
|
|
15
|
-
const PINS = '
|
|
14
|
+
const OBJECTS = 'objects';
|
|
15
|
+
const PINS = 'resourcePins';
|
|
16
16
|
function captureRevisions(input) {
|
|
17
17
|
if (input === undefined)
|
|
18
18
|
return;
|
|
@@ -25,39 +25,54 @@ function checkRevisions(records, expected) {
|
|
|
25
25
|
if (expected && (records.length !== Object.keys(expected).length || records.some(record => !Object.hasOwn(expected, record.id) || expected[record.id] !== record.revision)))
|
|
26
26
|
throw new BundleError('BUNDLE_CONFLICT', 'Browser bundles changed while contributions were being validated');
|
|
27
27
|
}
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
].filter((name) => typeof name === 'string'))];
|
|
28
|
+
function storedBundle(bundle) {
|
|
29
|
+
const { resources, ...metadata } = bundle;
|
|
30
|
+
return { id: bundle.manifest.id, revision: bundle.revision,
|
|
31
|
+
byteLength: Object.values(resources).reduce((sum, bytes) => sum + bytes.byteLength, 0), bundle: metadata };
|
|
33
32
|
}
|
|
34
|
-
function
|
|
35
|
-
const
|
|
36
|
-
for (const record of records)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
function bundleObjectHashes(records) {
|
|
34
|
+
const hashes = new Set();
|
|
35
|
+
for (const record of records) {
|
|
36
|
+
const resources = record?.bundle?.manifest?.resources;
|
|
37
|
+
if (!resources || typeof resources !== 'object' || Array.isArray(resources))
|
|
38
|
+
continue;
|
|
39
|
+
for (const resource of Object.values(resources))
|
|
40
|
+
if (resource && typeof resource.sha256 === 'string' && /^[a-f0-9]{64}$/.test(resource.sha256))
|
|
41
|
+
hashes.add(resource.sha256);
|
|
42
|
+
}
|
|
43
|
+
return hashes;
|
|
44
|
+
}
|
|
45
|
+
function referencedObjects(records, resources, pins) {
|
|
46
|
+
const referenced = bundleObjectHashes(records);
|
|
47
|
+
for (const pin of pins)
|
|
48
|
+
referenced.add(pin.sha256);
|
|
49
|
+
return resources.filter(resource => referenced.has(resource.sha256));
|
|
43
50
|
}
|
|
44
|
-
function
|
|
51
|
+
function bundleObjects(bundles) {
|
|
45
52
|
const result = new Map();
|
|
46
53
|
for (const bundle of bundles)
|
|
47
|
-
for (const name of
|
|
48
|
-
|
|
49
|
-
if (resource && typeof resource.sha256 === 'string' && /^[a-f0-9]{64}$/.test(resource.sha256) && bytes instanceof Uint8Array && bytes.byteLength === resource.byteLength)
|
|
50
|
-
result.set(resource.sha256, { sha256: resource.sha256, bytes });
|
|
51
|
-
}
|
|
54
|
+
for (const [name, resource] of Object.entries(bundle.manifest.resources))
|
|
55
|
+
result.set(resource.sha256, { sha256: resource.sha256, bytes: bundle.resources[name] });
|
|
52
56
|
return [...result.values()];
|
|
53
57
|
}
|
|
58
|
+
/** Schedule object reads inside the metadata transaction, preserving one consistent snapshot. */
|
|
59
|
+
function readObjects(records, store) {
|
|
60
|
+
const result = new Map();
|
|
61
|
+
const hashes = bundleObjectHashes(records);
|
|
62
|
+
for (const hash of hashes) {
|
|
63
|
+
const request = store.get(hash);
|
|
64
|
+
request.onsuccess = () => { if (request.result)
|
|
65
|
+
result.set(hash, request.result); };
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
54
69
|
function checkStorageBudget(records, runtimes) {
|
|
55
70
|
if (records.some(record => !Number.isSafeInteger(record.byteLength) || record.byteLength < 0 || record.byteLength > MAX_BUNDLE_BYTES) ||
|
|
56
71
|
runtimes.some(runtime => !(runtime.bytes instanceof Uint8Array) || runtime.bytes.byteLength > MAX_BUNDLE_BYTES)) {
|
|
57
72
|
throw new BundleError('BUNDLE_INTEGRITY', 'Stored resource size is invalid');
|
|
58
73
|
}
|
|
59
74
|
const retained = runtimes.reduce((sum, runtime) => sum + runtime.bytes.byteLength, 0);
|
|
60
|
-
const total =
|
|
75
|
+
const total = retained;
|
|
61
76
|
if (records.length > 64 || !Number.isSafeInteger(total) || total > MAX_BROWSER_STORAGE_BYTES)
|
|
62
77
|
throw new BundleError('BUNDLE_QUOTA', 'Installed and retained resources exceed the browser storage budget');
|
|
63
78
|
}
|
|
@@ -71,14 +86,14 @@ export class BrowserBundleStore {
|
|
|
71
86
|
if (this.database)
|
|
72
87
|
return this.database;
|
|
73
88
|
this.database = new Promise((resolve, reject) => {
|
|
74
|
-
const request = (this.options.indexedDB ?? indexedDB).open(this.options.databaseName ?? 'mearl-
|
|
89
|
+
const request = (this.options.indexedDB ?? indexedDB).open(this.options.databaseName ?? 'mearl-capabilities-v3', 1);
|
|
75
90
|
request.onupgradeneeded = () => {
|
|
76
91
|
if (!request.result.objectStoreNames.contains(STORE))
|
|
77
92
|
request.result.createObjectStore(STORE, { keyPath: 'id' });
|
|
78
93
|
if (!request.result.objectStoreNames.contains(PRESET))
|
|
79
94
|
request.result.createObjectStore(PRESET);
|
|
80
|
-
if (!request.result.objectStoreNames.contains(
|
|
81
|
-
request.result.createObjectStore(
|
|
95
|
+
if (!request.result.objectStoreNames.contains(OBJECTS))
|
|
96
|
+
request.result.createObjectStore(OBJECTS, { keyPath: 'sha256' });
|
|
82
97
|
if (!request.result.objectStoreNames.contains(PINS))
|
|
83
98
|
request.result.createObjectStore(PINS, { keyPath: 'owner' });
|
|
84
99
|
};
|
|
@@ -100,30 +115,41 @@ export class BrowserBundleStore {
|
|
|
100
115
|
async records() {
|
|
101
116
|
const database = await this.open();
|
|
102
117
|
return new Promise((resolve, reject) => {
|
|
103
|
-
const transaction = database.transaction([STORE, PRESET], 'readonly');
|
|
118
|
+
const transaction = database.transaction([STORE, PRESET, OBJECTS], 'readonly');
|
|
104
119
|
const request = transaction.objectStore(STORE).getAll();
|
|
120
|
+
let objects = new Map();
|
|
121
|
+
request.onsuccess = () => { objects = readObjects(request.result, transaction.objectStore(OBJECTS)); };
|
|
105
122
|
const active = transaction.objectStore(PRESET).get('active');
|
|
106
123
|
const installation = transaction.objectStore(PRESET).get('installation');
|
|
107
124
|
const token = transaction.objectStore(PRESET).get('installationToken');
|
|
108
|
-
transaction.oncomplete = () => resolve({ records: request.result, active: active.result, installation: installation.result, installationToken: token.result ?? null });
|
|
125
|
+
transaction.oncomplete = () => resolve({ objects, records: request.result, active: active.result, installation: installation.result, installationToken: token.result ?? null });
|
|
109
126
|
transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Bundle read failed'));
|
|
110
127
|
});
|
|
111
128
|
}
|
|
112
129
|
async get(id) {
|
|
113
130
|
const database = await this.open();
|
|
131
|
+
let objects = new Map();
|
|
114
132
|
const record = await new Promise((resolve, reject) => {
|
|
115
|
-
const transaction = database.transaction(STORE, 'readonly');
|
|
133
|
+
const transaction = database.transaction([STORE, OBJECTS], 'readonly');
|
|
116
134
|
const request = transaction.objectStore(STORE).get(id);
|
|
135
|
+
request.onsuccess = () => { objects = readObjects(request.result ? [request.result] : [], transaction.objectStore(OBJECTS)); };
|
|
117
136
|
transaction.oncomplete = () => resolve(request.result);
|
|
118
137
|
transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Bundle read failed'));
|
|
119
138
|
});
|
|
120
|
-
return record ? this.verified(record) : undefined;
|
|
139
|
+
return record ? this.verified(record, objects) : undefined;
|
|
121
140
|
}
|
|
122
|
-
async verified(record) {
|
|
141
|
+
async verified(record, objects) {
|
|
123
142
|
if (record.id !== record.bundle?.manifest?.id || record.revision !== record.bundle?.revision) {
|
|
124
143
|
throw new BundleError('BUNDLE_INTEGRITY', 'Stored bundle identity does not match');
|
|
125
144
|
}
|
|
126
|
-
const
|
|
145
|
+
const resources = {};
|
|
146
|
+
for (const [name, metadata] of Object.entries(record.bundle.manifest.resources)) {
|
|
147
|
+
const object = objects.get(metadata.sha256);
|
|
148
|
+
if (!object || object.sha256 !== metadata.sha256)
|
|
149
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Stored resource integrity check failed: object is missing');
|
|
150
|
+
resources[name] = object.bytes;
|
|
151
|
+
}
|
|
152
|
+
const bundle = await verifyBundle({ ...record.bundle, resources }, this.options.mearlApiVersion);
|
|
127
153
|
if (Object.values(bundle.resources).reduce((sum, bytes) => sum + bytes.byteLength, 0) !== record.byteLength) {
|
|
128
154
|
throw new BundleError('BUNDLE_INTEGRITY', 'Stored resource size does not match');
|
|
129
155
|
}
|
|
@@ -132,10 +158,10 @@ export class BrowserBundleStore {
|
|
|
132
158
|
async list() {
|
|
133
159
|
const bundles = [];
|
|
134
160
|
const errors = [];
|
|
135
|
-
const { records, active, installation: storedInstallation, installationToken } = await this.records();
|
|
161
|
+
const { objects, records, active, installation: storedInstallation, installationToken } = await this.records();
|
|
136
162
|
for (const record of records) {
|
|
137
163
|
try {
|
|
138
|
-
bundles.push(await this.verified(record));
|
|
164
|
+
bundles.push(await this.verified(record, objects));
|
|
139
165
|
}
|
|
140
166
|
catch (error) {
|
|
141
167
|
errors.push({ id: record.id, error: error instanceof Error ? error.message : 'Invalid stored bundle' });
|
|
@@ -191,8 +217,8 @@ export class BrowserBundleStore {
|
|
|
191
217
|
throw new BundleError('BUNDLE_INVALID', 'Invalid browser runtime digest');
|
|
192
218
|
const database = await this.open();
|
|
193
219
|
const record = await new Promise((resolve, reject) => {
|
|
194
|
-
const transaction = database.transaction(
|
|
195
|
-
const request = transaction.objectStore(
|
|
220
|
+
const transaction = database.transaction(OBJECTS, 'readonly');
|
|
221
|
+
const request = transaction.objectStore(OBJECTS).get(sha256);
|
|
196
222
|
transaction.oncomplete = () => resolve(request.result);
|
|
197
223
|
transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Browser runtime read failed'));
|
|
198
224
|
});
|
|
@@ -228,8 +254,8 @@ export class BrowserBundleStore {
|
|
|
228
254
|
});
|
|
229
255
|
const database = await this.open();
|
|
230
256
|
await new Promise((resolve, reject) => {
|
|
231
|
-
const transaction = database.transaction([STORE,
|
|
232
|
-
const runtimeStore = transaction.objectStore(
|
|
257
|
+
const transaction = database.transaction([STORE, OBJECTS, PINS], 'readwrite');
|
|
258
|
+
const runtimeStore = transaction.objectStore(OBJECTS);
|
|
233
259
|
const pinStore = transaction.objectStore(PINS);
|
|
234
260
|
const records = transaction.objectStore(STORE).getAll();
|
|
235
261
|
const resources = runtimeStore.getAll();
|
|
@@ -237,26 +263,7 @@ export class BrowserBundleStore {
|
|
|
237
263
|
let failure;
|
|
238
264
|
request.onsuccess = () => {
|
|
239
265
|
const cached = new Map(resources.result.map(resource => [resource.sha256, resource]));
|
|
240
|
-
const missing = new Set(patch.flatMap(({ sha256 }) => sha256 && !cached.has(sha256) ? [sha256] : []));
|
|
241
|
-
let added = false;
|
|
242
|
-
for (const runtime of runtimeResources(records.result.map(record => record.bundle))) {
|
|
243
|
-
if (missing.has(runtime.sha256)) {
|
|
244
|
-
cached.set(runtime.sha256, runtime);
|
|
245
|
-
runtimeStore.put(runtime);
|
|
246
|
-
added = true;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
266
|
const available = [...cached.values()];
|
|
250
|
-
if (added) {
|
|
251
|
-
try {
|
|
252
|
-
checkStorageBudget(records.result, available);
|
|
253
|
-
}
|
|
254
|
-
catch (error) {
|
|
255
|
-
failure = error;
|
|
256
|
-
transaction.abort();
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
267
|
const pins = new Map(replace ? [] : request.result.map((pin) => [pin.owner, pin]));
|
|
261
268
|
for (const { owner, sha256 } of patch) {
|
|
262
269
|
if (sha256 === null) {
|
|
@@ -273,7 +280,7 @@ export class BrowserBundleStore {
|
|
|
273
280
|
pinStore.clear();
|
|
274
281
|
for (const pin of pins.values())
|
|
275
282
|
pinStore.put(pin);
|
|
276
|
-
const retained = new Set(
|
|
283
|
+
const retained = new Set(referencedObjects(records.result, available, [...pins.values()]).map(runtime => runtime.sha256));
|
|
277
284
|
for (const runtime of available)
|
|
278
285
|
if (!retained.has(runtime.sha256))
|
|
279
286
|
runtimeStore.delete(runtime.sha256);
|
|
@@ -290,10 +297,12 @@ export class BrowserBundleStore {
|
|
|
290
297
|
return undefined;
|
|
291
298
|
const id = target.split('/')[0];
|
|
292
299
|
const database = await this.open();
|
|
300
|
+
let objects = new Map();
|
|
293
301
|
const record = await new Promise((resolve, reject) => {
|
|
294
|
-
const transaction = database.transaction([PRESET, STORE], 'readonly');
|
|
302
|
+
const transaction = database.transaction([PRESET, STORE, OBJECTS], 'readonly');
|
|
295
303
|
const current = transaction.objectStore(PRESET).get('active');
|
|
296
304
|
const bundle = transaction.objectStore(STORE).get(id);
|
|
305
|
+
bundle.onsuccess = () => { objects = readObjects(bundle.result ? [bundle.result] : [], transaction.objectStore(OBJECTS)); };
|
|
297
306
|
transaction.oncomplete = () => {
|
|
298
307
|
if (current.result?.revision !== activation.snapshot.revision) {
|
|
299
308
|
reject(new BundleError('BUNDLE_CONFLICT', 'The selected preset changed during profile lookup'));
|
|
@@ -306,7 +315,7 @@ export class BrowserBundleStore {
|
|
|
306
315
|
});
|
|
307
316
|
if (!record || record.revision !== activation.snapshot.bundleRevisions[id])
|
|
308
317
|
throw new BundleError('BUNDLE_INTEGRITY', 'The selected app provider is missing');
|
|
309
|
-
return { target, bundle: await this.verified(record) };
|
|
318
|
+
return { target, bundle: await this.verified(record, objects) };
|
|
310
319
|
}
|
|
311
320
|
/** Packages and the exact preset graph become visible together, after host contribution checks. */
|
|
312
321
|
async activatePreset(input, replacements, expectedRevision, association, expectedBundleRevisions) {
|
|
@@ -323,11 +332,11 @@ export class BrowserBundleStore {
|
|
|
323
332
|
const token = accepted ? await bundleDigest(crypto.getRandomValues(new Uint8Array(32))) : null;
|
|
324
333
|
const database = await this.open();
|
|
325
334
|
await new Promise((resolve, reject) => {
|
|
326
|
-
const transaction = database.transaction([STORE, PRESET,
|
|
335
|
+
const transaction = database.transaction([STORE, PRESET, OBJECTS, PINS], 'readwrite');
|
|
327
336
|
const store = transaction.objectStore(STORE);
|
|
328
337
|
const presets = transaction.objectStore(PRESET);
|
|
329
338
|
const records = store.getAll();
|
|
330
|
-
const runtimeStore = transaction.objectStore(
|
|
339
|
+
const runtimeStore = transaction.objectStore(OBJECTS);
|
|
331
340
|
const retained = runtimeStore.getAll();
|
|
332
341
|
const pins = transaction.objectStore(PINS).getAll();
|
|
333
342
|
const installation = presets.get('installationToken');
|
|
@@ -356,12 +365,12 @@ export class BrowserBundleStore {
|
|
|
356
365
|
}
|
|
357
366
|
const result = records.result.filter(record => !replacing.has(record.id) && !previousManaged.has(record.id));
|
|
358
367
|
for (const { bundle } of next)
|
|
359
|
-
result.push(
|
|
360
|
-
const resources =
|
|
368
|
+
result.push(storedBundle(bundle));
|
|
369
|
+
const resources = bundleObjects(next.map(item => item.bundle));
|
|
361
370
|
const cache = new Map(retained.result.map(runtime => [runtime.sha256, runtime]));
|
|
362
371
|
for (const runtime of resources)
|
|
363
372
|
cache.set(runtime.sha256, runtime);
|
|
364
|
-
const keeping =
|
|
373
|
+
const keeping = referencedObjects(result, [...cache.values()], pins.result);
|
|
365
374
|
try {
|
|
366
375
|
checkStorageBudget(result, keeping);
|
|
367
376
|
}
|
|
@@ -400,21 +409,18 @@ export class BrowserBundleStore {
|
|
|
400
409
|
async install(bundle, expectedRevision, expectedBundleRevisions) {
|
|
401
410
|
const selection = captureRevisions(expectedBundleRevisions);
|
|
402
411
|
const verified = await verifyBundle(bundle, this.options.mearlApiVersion);
|
|
403
|
-
|
|
404
|
-
await this.change(verified.manifest.id, expectedRevision, {
|
|
405
|
-
id: verified.manifest.id, revision: verified.revision, byteLength, bundle: verified,
|
|
406
|
-
}, selection);
|
|
412
|
+
await this.change(verified.manifest.id, expectedRevision, storedBundle(verified), selection, bundleObjects([verified]));
|
|
407
413
|
}
|
|
408
414
|
async remove(id, expectedRevision) {
|
|
409
415
|
await this.change(id, expectedRevision);
|
|
410
416
|
}
|
|
411
|
-
async change(id, expectedRevision, next, selection) {
|
|
417
|
+
async change(id, expectedRevision, next, selection, resources = []) {
|
|
412
418
|
const database = await this.open();
|
|
413
419
|
await new Promise((resolve, reject) => {
|
|
414
|
-
const transaction = database.transaction([STORE, PRESET,
|
|
420
|
+
const transaction = database.transaction([STORE, PRESET, OBJECTS, PINS], 'readwrite');
|
|
415
421
|
const store = transaction.objectStore(STORE);
|
|
416
422
|
const active = transaction.objectStore(PRESET).get('active');
|
|
417
|
-
const runtimeStore = transaction.objectStore(
|
|
423
|
+
const runtimeStore = transaction.objectStore(OBJECTS);
|
|
418
424
|
const retained = runtimeStore.getAll();
|
|
419
425
|
const pins = transaction.objectStore(PINS).getAll();
|
|
420
426
|
let failure;
|
|
@@ -442,11 +448,10 @@ export class BrowserBundleStore {
|
|
|
442
448
|
}
|
|
443
449
|
if (next) {
|
|
444
450
|
const others = records.filter(record => record.id !== id);
|
|
445
|
-
const resources = runtimeResources([next.bundle]);
|
|
446
451
|
const cache = new Map(retained.result.map(runtime => [runtime.sha256, runtime]));
|
|
447
452
|
for (const runtime of resources)
|
|
448
453
|
cache.set(runtime.sha256, runtime);
|
|
449
|
-
const keeping =
|
|
454
|
+
const keeping = referencedObjects([...others, next], [...cache.values()], pins.result);
|
|
450
455
|
try {
|
|
451
456
|
checkStorageBudget([...others, next], keeping);
|
|
452
457
|
}
|
|
@@ -465,7 +470,7 @@ export class BrowserBundleStore {
|
|
|
465
470
|
}
|
|
466
471
|
else {
|
|
467
472
|
store.delete(id);
|
|
468
|
-
const keep = new Set(
|
|
473
|
+
const keep = new Set(referencedObjects(records.filter(record => record.id !== id), retained.result, pins.result).map(runtime => runtime.sha256));
|
|
469
474
|
for (const runtime of retained.result)
|
|
470
475
|
if (!keep.has(runtime.sha256))
|
|
471
476
|
runtimeStore.delete(runtime.sha256);
|