sh3-core 0.24.0 → 0.25.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/BrandSlot.svelte +62 -3
- package/dist/BrandSlot.test.js +52 -0
- package/dist/apps/types.d.ts +8 -0
- package/dist/artifact.d.ts +7 -0
- package/dist/build.d.ts +8 -0
- package/dist/build.js +17 -7
- package/dist/build.test.js +27 -1
- package/dist/layout/store.svelte.js +1 -1
- package/dist/overlays/presets.d.ts +17 -2
- package/dist/overlays/presets.js +28 -2
- package/dist/overlays/presets.test.js +29 -0
- package/dist/platform/localSidecar.d.ts +7 -0
- package/dist/platform/localSidecar.js +24 -0
- package/dist/platform/localSidecar.test.d.ts +1 -0
- package/dist/platform/localSidecar.test.js +39 -0
- package/dist/registry/installer.js +50 -10
- package/dist/registry/installer.test.d.ts +1 -0
- package/dist/registry/installer.test.js +146 -0
- package/dist/registry/types.d.ts +19 -0
- package/dist/runtime/runVerb.test.js +87 -0
- package/dist/sh3core-shard/folderActions.d.ts +15 -0
- package/dist/sh3core-shard/folderActions.js +109 -0
- package/dist/sh3core-shard/folderActions.test.d.ts +1 -0
- package/dist/sh3core-shard/folderActions.test.js +43 -0
- package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -0
- package/dist/shards/lifecycle.svelte.d.ts +8 -0
- package/dist/shards/lifecycle.svelte.js +17 -0
- package/dist/shell-shard/verbs/xfer.js +66 -4
- package/dist/shell-shard/verbs/xfer.test.js +74 -0
- package/dist/verbs/types.d.ts +49 -12
- package/dist/verbs/types.test.d.ts +1 -0
- package/dist/verbs/types.test.js +43 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/BrandSlot.svelte
CHANGED
|
@@ -15,6 +15,15 @@
|
|
|
15
15
|
import { getBreadcrumbAppId, getRegisteredApp } from './apps/registry.svelte';
|
|
16
16
|
import { sessionState, switchProjectScope } from './projects/session-state.svelte';
|
|
17
17
|
import { projectsState } from './projects-shard/projectsShard.svelte';
|
|
18
|
+
import { presetManager } from './overlays/presets';
|
|
19
|
+
|
|
20
|
+
function titleCasePresetName(name: string): string {
|
|
21
|
+
return name
|
|
22
|
+
.split(/[-_]/)
|
|
23
|
+
.filter((part) => part.length > 0)
|
|
24
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
25
|
+
.join(' ');
|
|
26
|
+
}
|
|
18
27
|
|
|
19
28
|
const activeAppId = $derived(getLiveDispatcherState().activeAppId);
|
|
20
29
|
const breadcrumbId = $derived(getBreadcrumbAppId());
|
|
@@ -30,15 +39,44 @@
|
|
|
30
39
|
projectId ? projectsState.projects.find((p) => p.id === projectId)?.name ?? projectId : null,
|
|
31
40
|
);
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
// Preset state is only meaningful while an app is attached. Existing
|
|
43
|
+
// BrandSlot tests set activeAppId without binding a preset blob, so the
|
|
44
|
+
// try/catch here is necessary, not just defensive.
|
|
45
|
+
const offDefault = $derived.by(() => {
|
|
46
|
+
if (activeAppId === null) return false;
|
|
47
|
+
try {
|
|
48
|
+
return presetManager.active() !== presetManager.default();
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const presetLabel = $derived.by(() => {
|
|
55
|
+
if (!offDefault) return null;
|
|
56
|
+
try {
|
|
57
|
+
return titleCasePresetName(presetManager.active());
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
type Mode =
|
|
64
|
+
| 'brand'
|
|
65
|
+
| 'app'
|
|
66
|
+
| 'app-off-default'
|
|
67
|
+
| 'breadcrumb'
|
|
68
|
+
| 'project-home'
|
|
69
|
+
| 'project-app'
|
|
70
|
+
| 'project-app-off-default'
|
|
71
|
+
| 'project-breadcrumb';
|
|
34
72
|
|
|
35
73
|
const mode: Mode = $derived.by(() => {
|
|
36
74
|
if (projectId) {
|
|
37
|
-
if (activeAppId) return 'project-app';
|
|
75
|
+
if (activeAppId) return offDefault ? 'project-app-off-default' : 'project-app';
|
|
38
76
|
if (breadcrumbId) return 'project-breadcrumb';
|
|
39
77
|
return 'project-home';
|
|
40
78
|
}
|
|
41
|
-
if (activeAppId) return 'app';
|
|
79
|
+
if (activeAppId) return offDefault ? 'app-off-default' : 'app';
|
|
42
80
|
if (breadcrumbId) return 'breadcrumb';
|
|
43
81
|
return 'brand';
|
|
44
82
|
});
|
|
@@ -54,6 +92,14 @@
|
|
|
54
92
|
function reenterProjectHome() {
|
|
55
93
|
if (activeAppId) void returnToHome();
|
|
56
94
|
}
|
|
95
|
+
|
|
96
|
+
function backToDefaultPreset() {
|
|
97
|
+
try {
|
|
98
|
+
presetManager.switchToDefault();
|
|
99
|
+
} catch {
|
|
100
|
+
// Blob unbound mid-click — nothing to do.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
57
103
|
</script>
|
|
58
104
|
|
|
59
105
|
<div class="sh3-brand-slot">
|
|
@@ -61,6 +107,10 @@
|
|
|
61
107
|
<span class="sh3-brand">SH3</span>
|
|
62
108
|
{:else if mode === 'app'}
|
|
63
109
|
<span class="sh3-brand sh3-brand-app">{activeLabel}</span>
|
|
110
|
+
{:else if mode === 'app-off-default'}
|
|
111
|
+
<button type="button" class="sh3-brand sh3-brand-app sh3-brand-clickable" onclick={backToDefaultPreset} title="Return to default layout">{activeLabel}</button>
|
|
112
|
+
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
113
|
+
<span class="sh3-brand-preset">{presetLabel}</span>
|
|
64
114
|
{:else if mode === 'breadcrumb'}
|
|
65
115
|
<span class="sh3-brand">SH3</span>
|
|
66
116
|
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
@@ -73,6 +123,12 @@
|
|
|
73
123
|
<span class="sh3-brand sh3-brand-project">{projectLabel}</span>
|
|
74
124
|
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
75
125
|
<span class="sh3-brand sh3-brand-app">{activeLabel}</span>
|
|
126
|
+
{:else if mode === 'project-app-off-default'}
|
|
127
|
+
<span class="sh3-brand sh3-brand-project">{projectLabel}</span>
|
|
128
|
+
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
129
|
+
<button type="button" class="sh3-brand sh3-brand-app sh3-brand-clickable" onclick={backToDefaultPreset} title="Return to default layout">{activeLabel}</button>
|
|
130
|
+
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
131
|
+
<span class="sh3-brand-preset">{presetLabel}</span>
|
|
76
132
|
{:else if mode === 'project-breadcrumb'}
|
|
77
133
|
<button type="button" class="sh3-brand sh3-brand-clickable" onclick={exitProject} title="Exit project">SH3</button>
|
|
78
134
|
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
@@ -126,4 +182,7 @@
|
|
|
126
182
|
.sh3-brand-clickable:hover {
|
|
127
183
|
background: var(--sh3-bg-elevated);
|
|
128
184
|
}
|
|
185
|
+
.sh3-brand-preset {
|
|
186
|
+
color: var(--sh3-fg-muted);
|
|
187
|
+
}
|
|
129
188
|
</style>
|
package/dist/BrandSlot.test.js
CHANGED
|
@@ -11,6 +11,7 @@ import BrandSlot from './BrandSlot.svelte';
|
|
|
11
11
|
import { setActiveApp, __resetDispatcherStateForTest } from './actions/state.svelte';
|
|
12
12
|
import { registerApp, __resetAppRegistryForTest, __resetBreadcrumbForTest, breadcrumbApp, } from './apps/registry.svelte';
|
|
13
13
|
import { launchApp } from './apps/lifecycle';
|
|
14
|
+
import { __bindPresetBlobForTest, __resetPresetManagerForTest, } from './overlays/presets';
|
|
14
15
|
let host;
|
|
15
16
|
let cmp = null;
|
|
16
17
|
function makeApp(id, label) {
|
|
@@ -27,6 +28,7 @@ beforeEach(() => {
|
|
|
27
28
|
document.body.appendChild(host);
|
|
28
29
|
__resetBreadcrumbForTest();
|
|
29
30
|
__resetDispatcherStateForTest();
|
|
31
|
+
__resetPresetManagerForTest();
|
|
30
32
|
});
|
|
31
33
|
afterEach(() => {
|
|
32
34
|
if (cmp) {
|
|
@@ -37,7 +39,20 @@ afterEach(() => {
|
|
|
37
39
|
__resetAppRegistryForTest();
|
|
38
40
|
__resetDispatcherStateForTest();
|
|
39
41
|
vi.clearAllMocks();
|
|
42
|
+
__resetPresetManagerForTest();
|
|
40
43
|
});
|
|
44
|
+
function bindBlobWithPresets(activePreset, names, defaultHint) {
|
|
45
|
+
const blob = {
|
|
46
|
+
layoutVersion: 1,
|
|
47
|
+
activePreset,
|
|
48
|
+
presets: Object.fromEntries(names.map((n) => [
|
|
49
|
+
n,
|
|
50
|
+
{ default: { docked: { type: 'slot', slotId: `${n}-s`, viewId: 'v' }, floats: [] } },
|
|
51
|
+
])),
|
|
52
|
+
};
|
|
53
|
+
__bindPresetBlobForTest(blob, defaultHint);
|
|
54
|
+
return blob;
|
|
55
|
+
}
|
|
41
56
|
describe('BrandSlot', () => {
|
|
42
57
|
it('renders SH3 when no app has launched this session', async () => {
|
|
43
58
|
var _a;
|
|
@@ -68,4 +83,41 @@ describe('BrandSlot', () => {
|
|
|
68
83
|
btn.click();
|
|
69
84
|
expect(launchApp).toHaveBeenCalledWith('app.a');
|
|
70
85
|
});
|
|
86
|
+
it('renders [App Name] (no preset chip) when active app is on default preset', async () => {
|
|
87
|
+
registerApp(makeApp('app.a', 'My App'));
|
|
88
|
+
breadcrumbApp.id = 'app.a';
|
|
89
|
+
setActiveApp('app.a', new Set());
|
|
90
|
+
bindBlobWithPresets('home', ['home', 'editing'], 'home');
|
|
91
|
+
cmp = mount(BrandSlot, { target: host, props: {} });
|
|
92
|
+
await tick();
|
|
93
|
+
expect(host.textContent).toContain('My App');
|
|
94
|
+
expect(host.textContent).not.toContain('Home');
|
|
95
|
+
expect(host.textContent).not.toContain('Editing');
|
|
96
|
+
// App segment is plain text (no button) in the default-preset case.
|
|
97
|
+
expect(host.querySelector('button')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
it('renders [App Name] › [Preset] with App clickable when off default', async () => {
|
|
100
|
+
registerApp(makeApp('app.a', 'My App'));
|
|
101
|
+
breadcrumbApp.id = 'app.a';
|
|
102
|
+
setActiveApp('app.a', new Set());
|
|
103
|
+
const blob = bindBlobWithPresets('editing', ['home', 'editing'], 'home');
|
|
104
|
+
cmp = mount(BrandSlot, { target: host, props: {} });
|
|
105
|
+
await tick();
|
|
106
|
+
expect(host.textContent).toMatch(/My App.*Editing/);
|
|
107
|
+
const btn = host.querySelector('button');
|
|
108
|
+
expect(btn).not.toBeNull();
|
|
109
|
+
expect(btn.textContent).toContain('My App');
|
|
110
|
+
btn.click();
|
|
111
|
+
await tick();
|
|
112
|
+
expect(blob.activePreset).toBe('home');
|
|
113
|
+
});
|
|
114
|
+
it('titlecases multi-word preset names with hyphens or underscores', async () => {
|
|
115
|
+
registerApp(makeApp('app.a', 'My App'));
|
|
116
|
+
breadcrumbApp.id = 'app.a';
|
|
117
|
+
setActiveApp('app.a', new Set());
|
|
118
|
+
bindBlobWithPresets('my-cool-preset', ['home', 'my-cool-preset'], 'home');
|
|
119
|
+
cmp = mount(BrandSlot, { target: host, props: {} });
|
|
120
|
+
await tick();
|
|
121
|
+
expect(host.textContent).toContain('My Cool Preset');
|
|
122
|
+
});
|
|
71
123
|
});
|
package/dist/apps/types.d.ts
CHANGED
|
@@ -95,6 +95,14 @@ export interface AppManifest {
|
|
|
95
95
|
* Optional default home-card color default to transparent if not set
|
|
96
96
|
*/
|
|
97
97
|
color?: string;
|
|
98
|
+
/**
|
|
99
|
+
* Name of the preset (from `initialLayout: LayoutPreset[]`) that is treated
|
|
100
|
+
* as the entry/home preset. When set, clicking the app segment in the
|
|
101
|
+
* breadcrumb returns the user here; framework helpers also use it as the
|
|
102
|
+
* "back to default" target. When omitted, the first preset in declaration
|
|
103
|
+
* order is used. Ignored when `initialLayout` is not an array of presets.
|
|
104
|
+
*/
|
|
105
|
+
defaultPreset?: string;
|
|
98
106
|
}
|
|
99
107
|
/**
|
|
100
108
|
* Context object passed to `App.activate`. Provides app-scoped state zones
|
package/dist/artifact.d.ts
CHANGED
|
@@ -10,6 +10,13 @@ export interface ArtifactManifest {
|
|
|
10
10
|
id: string;
|
|
11
11
|
/** Whether this is a shard or app (or both via combo bundle). */
|
|
12
12
|
type: 'shard' | 'app' | 'combo';
|
|
13
|
+
/**
|
|
14
|
+
* Shard kind. Carried over from the source shard manifest so the server's
|
|
15
|
+
* project-allowlist middleware can auto-allow system-kind shards and
|
|
16
|
+
* resolve service-kind shards from `project.shardAllowlist`. Only set
|
|
17
|
+
* for `type: 'shard'`; apps and combos omit it.
|
|
18
|
+
*/
|
|
19
|
+
kind?: 'system' | 'service';
|
|
13
20
|
/** Human-readable display name. */
|
|
14
21
|
label: string;
|
|
15
22
|
/** Version string (semver). */
|
package/dist/build.d.ts
CHANGED
|
@@ -82,6 +82,14 @@ export declare function composeArtifactVersion(pkgVersion: string, suffix: strin
|
|
|
82
82
|
* Exported for testing; used internally by sh3Artifact.
|
|
83
83
|
*/
|
|
84
84
|
export declare function extractRequiredShardsFromBundle(bundleSource: string): string[];
|
|
85
|
+
/**
|
|
86
|
+
* Read a shard `kind` field (`system` or `service`) from a single manifest
|
|
87
|
+
* block. Returns undefined when absent or set to any other value. Exported
|
|
88
|
+
* for testing; used by `sh3Artifact` to propagate the shard's runtime kind
|
|
89
|
+
* into the emitted `manifest.json` so server-side allowlist resolution can
|
|
90
|
+
* see it.
|
|
91
|
+
*/
|
|
92
|
+
export declare function extractShardKindFromBlock(block: string): 'system' | 'service' | undefined;
|
|
85
93
|
/**
|
|
86
94
|
* Collect all shard ids present in a bundle by scanning every `views: [` block.
|
|
87
95
|
* Exported for testing.
|
package/dist/build.js
CHANGED
|
@@ -159,6 +159,17 @@ export function extractRequiredShardsFromBundle(bundleSource) {
|
|
|
159
159
|
ids.push(m[1]);
|
|
160
160
|
return ids;
|
|
161
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Read a shard `kind` field (`system` or `service`) from a single manifest
|
|
164
|
+
* block. Returns undefined when absent or set to any other value. Exported
|
|
165
|
+
* for testing; used by `sh3Artifact` to propagate the shard's runtime kind
|
|
166
|
+
* into the emitted `manifest.json` so server-side allowlist resolution can
|
|
167
|
+
* see it.
|
|
168
|
+
*/
|
|
169
|
+
export function extractShardKindFromBlock(block) {
|
|
170
|
+
const m = block.match(/\bkind\s*:\s*["'](system|service)["']/);
|
|
171
|
+
return m ? m[1] : undefined;
|
|
172
|
+
}
|
|
162
173
|
/**
|
|
163
174
|
* Collect all shard ids present in a bundle by scanning every `views: [` block.
|
|
164
175
|
* Exported for testing.
|
|
@@ -301,15 +312,14 @@ export function sh3Artifact(options = {}) {
|
|
|
301
312
|
};
|
|
302
313
|
// Extract the requiredShards string-id array from the app manifest block.
|
|
303
314
|
const requiredShards = extractRequiredShardsFromBundle(block);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
};
|
|
315
|
+
// Extract shard `kind` (system/service). Only meaningful when the
|
|
316
|
+
// anchored block is a shard block; app blocks won't carry it.
|
|
317
|
+
const kind = extractShardKindFromBlock(block);
|
|
318
|
+
return Object.assign(Object.assign({ id: get(/\bid\s*:\s*["']([^"']+)["']/), label: get(/\blabel\s*:\s*["']([^"']+)["']/) }, (kind ? { kind } : {})), { requiredShards });
|
|
309
319
|
}
|
|
310
320
|
// App first, then Shard.
|
|
311
321
|
const extracted = (_a = extractFromBlock(/\brequiredShards\s*:\s*\[/)) !== null && _a !== void 0 ? _a : extractFromBlock(/\bviews\s*:\s*\[/);
|
|
312
|
-
const { id, label } = extracted;
|
|
322
|
+
const { id, label, kind } = extracted;
|
|
313
323
|
let { requiredShards } = extracted;
|
|
314
324
|
// Strip any shard ids that are bundled in this artifact from requiredShards.
|
|
315
325
|
const bundledShardIds = extractBundledShardIds(source);
|
|
@@ -362,7 +372,7 @@ export function sh3Artifact(options = {}) {
|
|
|
362
372
|
if (!finalAuthor) {
|
|
363
373
|
throw new Error('[sh3-artifact] Missing "author". Add it to package.json or pass it via sh3Artifact({ manifest: { author } }).');
|
|
364
374
|
}
|
|
365
|
-
const manifest = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ id: id || 'unknown', type, label: label || id || 'unknown', version: artifactVersion, contractVersion: 1 }, (hasServer ? { server: 'server.js' } : {})), { description: finalDescription, author: finalAuthor }), ((type === 'app' || type === 'combo') ? { requiredShards } : {})), (type === 'combo' && bundledShardIds.size > 0 ? { bundledShards: [...bundledShardIds] } : {})), overrides);
|
|
375
|
+
const manifest = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ id: id || 'unknown', type, label: label || id || 'unknown', version: artifactVersion, contractVersion: 1 }, (type === 'shard' && kind ? { kind } : {})), (hasServer ? { server: 'server.js' } : {})), { description: finalDescription, author: finalAuthor }), ((type === 'app' || type === 'combo') ? { requiredShards } : {})), (type === 'combo' && bundledShardIds.size > 0 ? { bundledShards: [...bundledShardIds] } : {})), overrides);
|
|
366
376
|
// Read the emitted JS files as bytes for the archive
|
|
367
377
|
const clientBytes = readFileSync(join(outDir, 'client.js'));
|
|
368
378
|
const serverBytes = hasServer ? readFileSync(join(outDir, 'server.js')) : undefined;
|
package/dist/build.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { composeArtifactVersion, extractRequiredShardsFromBundle, extractBundledShardIds } from './build';
|
|
2
|
+
import { composeArtifactVersion, extractRequiredShardsFromBundle, extractBundledShardIds, extractShardKindFromBlock } from './build';
|
|
3
3
|
describe('composeArtifactVersion', () => {
|
|
4
4
|
it('returns pkgVersion unchanged when suffix is undefined', () => {
|
|
5
5
|
expect(composeArtifactVersion('1.2.3', undefined)).toBe('1.2.3');
|
|
@@ -84,3 +84,29 @@ describe('extractBundledShardIds', () => {
|
|
|
84
84
|
expect(filtered).toEqual(['external-dep']);
|
|
85
85
|
});
|
|
86
86
|
});
|
|
87
|
+
describe('extractShardKindFromBlock', () => {
|
|
88
|
+
it('extracts kind: "system"', () => {
|
|
89
|
+
const block = `{ id: "ai", label: "AI", kind: "system", views: [] }`;
|
|
90
|
+
expect(extractShardKindFromBlock(block)).toBe('system');
|
|
91
|
+
});
|
|
92
|
+
it('extracts kind: "service"', () => {
|
|
93
|
+
const block = `{ id: "registry", kind: "service", views: [] }`;
|
|
94
|
+
expect(extractShardKindFromBlock(block)).toBe('service');
|
|
95
|
+
});
|
|
96
|
+
it('handles single-quoted kind', () => {
|
|
97
|
+
const block = `{ id: 'ai', kind: 'system', views: [] }`;
|
|
98
|
+
expect(extractShardKindFromBlock(block)).toBe('system');
|
|
99
|
+
});
|
|
100
|
+
it('handles minified format without spaces', () => {
|
|
101
|
+
const block = `{id:"ai",kind:"system",views:[]}`;
|
|
102
|
+
expect(extractShardKindFromBlock(block)).toBe('system');
|
|
103
|
+
});
|
|
104
|
+
it('returns undefined when kind is absent', () => {
|
|
105
|
+
const block = `{ id: "plain-shard", label: "Plain", views: [] }`;
|
|
106
|
+
expect(extractShardKindFromBlock(block)).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
it('returns undefined for unrecognized kind values', () => {
|
|
109
|
+
const block = `{ id: "weird", kind: "widget", views: [] }`;
|
|
110
|
+
expect(extractShardKindFromBlock(block)).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -142,7 +142,7 @@ export function attachApp(app) {
|
|
|
142
142
|
// their view factories. Binding the preset manager proxy happens here
|
|
143
143
|
// so shards can read/switch presets from their activate() hook.
|
|
144
144
|
appEntry = { appId: app.manifest.id, proxy, heldSlotIds: [] };
|
|
145
|
-
bindPresetBlob(proxy);
|
|
145
|
+
bindPresetBlob(proxy, app.manifest.defaultPreset);
|
|
146
146
|
bindDrawerStoreToBlob(proxy);
|
|
147
147
|
}
|
|
148
148
|
/**
|
|
@@ -6,16 +6,31 @@ export interface PresetManager {
|
|
|
6
6
|
active(): string;
|
|
7
7
|
/** Switch to the named preset. Throws if unknown. */
|
|
8
8
|
switch(name: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Resolved default preset name. Returns the hint passed at bind time
|
|
11
|
+
* when it matches a known preset, otherwise the first preset in
|
|
12
|
+
* declaration order. Throws when no app is attached.
|
|
13
|
+
*/
|
|
14
|
+
default(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Switch to the resolved default. No-op when already on default.
|
|
17
|
+
* Throws when no app is attached.
|
|
18
|
+
*/
|
|
19
|
+
switchToDefault(): void;
|
|
9
20
|
}
|
|
10
21
|
/**
|
|
11
22
|
* Bind the manager to the attached app's `AppLayoutBlob` workspace-zone
|
|
12
23
|
* proxy. Called from `attachApp` in the layout store.
|
|
24
|
+
*
|
|
25
|
+
* @param defaultPresetHint - Optional name from `AppManifest.defaultPreset`.
|
|
26
|
+
* Used by `default()` / `switchToDefault()`. Falls back to first preset
|
|
27
|
+
* when omitted or when the hint doesn't match any preset.
|
|
13
28
|
*/
|
|
14
|
-
export declare function bindPresetBlob(blob: AppLayoutBlob): void;
|
|
29
|
+
export declare function bindPresetBlob(blob: AppLayoutBlob, defaultPresetHint?: string): void;
|
|
15
30
|
/** Unbind on detach. Called from `detachApp`. */
|
|
16
31
|
export declare function unbindPresetBlob(): void;
|
|
17
32
|
/** Test-only bind alias for tests that build a synthetic blob. */
|
|
18
|
-
export declare function __bindPresetBlobForTest(blob: AppLayoutBlob): void;
|
|
33
|
+
export declare function __bindPresetBlobForTest(blob: AppLayoutBlob, defaultPresetHint?: string): void;
|
|
19
34
|
/** Test-only reset. Clears the binding. */
|
|
20
35
|
export declare function __resetPresetManagerForTest(): void;
|
|
21
36
|
export declare const presetManager: PresetManager;
|
package/dist/overlays/presets.js
CHANGED
|
@@ -18,24 +18,33 @@
|
|
|
18
18
|
* all methods throw — there is no pre-boot fallback.
|
|
19
19
|
*/
|
|
20
20
|
let boundBlob = null;
|
|
21
|
+
let boundDefaultHint = null;
|
|
21
22
|
/**
|
|
22
23
|
* Bind the manager to the attached app's `AppLayoutBlob` workspace-zone
|
|
23
24
|
* proxy. Called from `attachApp` in the layout store.
|
|
25
|
+
*
|
|
26
|
+
* @param defaultPresetHint - Optional name from `AppManifest.defaultPreset`.
|
|
27
|
+
* Used by `default()` / `switchToDefault()`. Falls back to first preset
|
|
28
|
+
* when omitted or when the hint doesn't match any preset.
|
|
24
29
|
*/
|
|
25
|
-
export function bindPresetBlob(blob) {
|
|
30
|
+
export function bindPresetBlob(blob, defaultPresetHint) {
|
|
26
31
|
boundBlob = blob;
|
|
32
|
+
boundDefaultHint = defaultPresetHint !== null && defaultPresetHint !== void 0 ? defaultPresetHint : null;
|
|
27
33
|
}
|
|
28
34
|
/** Unbind on detach. Called from `detachApp`. */
|
|
29
35
|
export function unbindPresetBlob() {
|
|
30
36
|
boundBlob = null;
|
|
37
|
+
boundDefaultHint = null;
|
|
31
38
|
}
|
|
32
39
|
/** Test-only bind alias for tests that build a synthetic blob. */
|
|
33
|
-
export function __bindPresetBlobForTest(blob) {
|
|
40
|
+
export function __bindPresetBlobForTest(blob, defaultPresetHint) {
|
|
34
41
|
boundBlob = blob;
|
|
42
|
+
boundDefaultHint = defaultPresetHint !== null && defaultPresetHint !== void 0 ? defaultPresetHint : null;
|
|
35
43
|
}
|
|
36
44
|
/** Test-only reset. Clears the binding. */
|
|
37
45
|
export function __resetPresetManagerForTest() {
|
|
38
46
|
boundBlob = null;
|
|
47
|
+
boundDefaultHint = null;
|
|
39
48
|
}
|
|
40
49
|
function requireBlob() {
|
|
41
50
|
if (!boundBlob) {
|
|
@@ -56,8 +65,25 @@ function switchPreset(name) {
|
|
|
56
65
|
}
|
|
57
66
|
blob.activePreset = name;
|
|
58
67
|
}
|
|
68
|
+
function resolveDefault() {
|
|
69
|
+
const blob = requireBlob();
|
|
70
|
+
const keys = Object.keys(blob.presets);
|
|
71
|
+
if (boundDefaultHint && boundDefaultHint in blob.presets) {
|
|
72
|
+
return boundDefaultHint;
|
|
73
|
+
}
|
|
74
|
+
return keys[0];
|
|
75
|
+
}
|
|
76
|
+
function switchToDefault() {
|
|
77
|
+
const target = resolveDefault();
|
|
78
|
+
const blob = requireBlob();
|
|
79
|
+
if (blob.activePreset === target)
|
|
80
|
+
return;
|
|
81
|
+
blob.activePreset = target;
|
|
82
|
+
}
|
|
59
83
|
export const presetManager = {
|
|
60
84
|
list: listPresets,
|
|
61
85
|
active: activePreset,
|
|
62
86
|
switch: switchPreset,
|
|
87
|
+
default: resolveDefault,
|
|
88
|
+
switchToDefault,
|
|
63
89
|
};
|
|
@@ -37,4 +37,33 @@ describe('presetManager', () => {
|
|
|
37
37
|
it('list() throws when no blob is bound', () => {
|
|
38
38
|
expect(() => presetManager.list()).toThrow(/no app attached/);
|
|
39
39
|
});
|
|
40
|
+
it('default() returns the explicit hint when it matches a known preset', () => {
|
|
41
|
+
__bindPresetBlobForTest(makeBlob('author', ['author', 'review', 'inspect']), 'review');
|
|
42
|
+
expect(presetManager.default()).toBe('review');
|
|
43
|
+
});
|
|
44
|
+
it('default() falls back to first preset when no hint is provided', () => {
|
|
45
|
+
__bindPresetBlobForTest(makeBlob('review', ['author', 'review']));
|
|
46
|
+
expect(presetManager.default()).toBe('author');
|
|
47
|
+
});
|
|
48
|
+
it('default() falls back to first preset when hint is unknown', () => {
|
|
49
|
+
__bindPresetBlobForTest(makeBlob('author', ['author', 'review']), 'nope');
|
|
50
|
+
expect(presetManager.default()).toBe('author');
|
|
51
|
+
});
|
|
52
|
+
it('default() throws when no blob is bound', () => {
|
|
53
|
+
expect(() => presetManager.default()).toThrow(/no app attached/);
|
|
54
|
+
});
|
|
55
|
+
it('switchToDefault() switches to the resolved default preset', () => {
|
|
56
|
+
const blob = makeBlob('review', ['author', 'review']);
|
|
57
|
+
__bindPresetBlobForTest(blob, 'author');
|
|
58
|
+
presetManager.switchToDefault();
|
|
59
|
+
expect(blob.activePreset).toBe('author');
|
|
60
|
+
expect(presetManager.active()).toBe('author');
|
|
61
|
+
});
|
|
62
|
+
it('switchToDefault() is a no-op when already on default', () => {
|
|
63
|
+
const blob = makeBlob('author', ['author', 'review']);
|
|
64
|
+
__bindPresetBlobForTest(blob, 'author');
|
|
65
|
+
const before = blob.activePreset;
|
|
66
|
+
presetManager.switchToDefault();
|
|
67
|
+
expect(blob.activePreset).toBe(before);
|
|
68
|
+
});
|
|
40
69
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* isLocalTauriDesktop — true iff we're in a Tauri webview AND the page
|
|
3
|
+
* was served by the local sh3-server sidecar (not the bundled frontend
|
|
4
|
+
* in remote-mode, and not Android's http://tauri.localhost shim).
|
|
5
|
+
*
|
|
6
|
+
* Synchronous: safe to call from action `disabled` predicates that run
|
|
7
|
+
* on every palette render. Logic is exposed via the pure
|
|
8
|
+
* `checkLocalTauriDesktop` so it can be unit-tested in the node project
|
|
9
|
+
* without a window/location shim.
|
|
10
|
+
*/
|
|
11
|
+
export function checkLocalTauriDesktop(env) {
|
|
12
|
+
return env.hasTauriInternals
|
|
13
|
+
&& env.protocol === 'http:'
|
|
14
|
+
&& env.hostname === 'localhost';
|
|
15
|
+
}
|
|
16
|
+
export function isLocalTauriDesktop() {
|
|
17
|
+
if (typeof window === 'undefined' || typeof location === 'undefined')
|
|
18
|
+
return false;
|
|
19
|
+
return checkLocalTauriDesktop({
|
|
20
|
+
hasTauriInternals: '__TAURI_INTERNALS__' in window,
|
|
21
|
+
protocol: location.protocol,
|
|
22
|
+
hostname: location.hostname,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { checkLocalTauriDesktop } from './localSidecar';
|
|
3
|
+
describe('checkLocalTauriDesktop', () => {
|
|
4
|
+
it('returns true for Tauri desktop sidecar (http://localhost)', () => {
|
|
5
|
+
expect(checkLocalTauriDesktop({
|
|
6
|
+
hasTauriInternals: true,
|
|
7
|
+
protocol: 'http:',
|
|
8
|
+
hostname: 'localhost',
|
|
9
|
+
})).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it('returns false in pure web (no Tauri)', () => {
|
|
12
|
+
expect(checkLocalTauriDesktop({
|
|
13
|
+
hasTauriInternals: false,
|
|
14
|
+
protocol: 'https:',
|
|
15
|
+
hostname: 'example.com',
|
|
16
|
+
})).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
it('returns false in Tauri remote-mode desktop (tauri://localhost)', () => {
|
|
19
|
+
expect(checkLocalTauriDesktop({
|
|
20
|
+
hasTauriInternals: true,
|
|
21
|
+
protocol: 'tauri:',
|
|
22
|
+
hostname: 'localhost',
|
|
23
|
+
})).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it('returns false on Tauri Android (http://tauri.localhost)', () => {
|
|
26
|
+
expect(checkLocalTauriDesktop({
|
|
27
|
+
hasTauriInternals: true,
|
|
28
|
+
protocol: 'http:',
|
|
29
|
+
hostname: 'tauri.localhost',
|
|
30
|
+
})).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
it('returns false for https://localhost (not the sidecar)', () => {
|
|
33
|
+
expect(checkLocalTauriDesktop({
|
|
34
|
+
hasTauriInternals: true,
|
|
35
|
+
protocol: 'https:',
|
|
36
|
+
hostname: 'localhost',
|
|
37
|
+
})).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* packages from IndexedDB and registers them.
|
|
16
16
|
*/
|
|
17
17
|
import { loadBundleModule } from './loader';
|
|
18
|
-
import { savePackage, loadBundle, listInstalled, removePackage } from './storage';
|
|
19
|
-
import { deactivateShard } from '../shards/lifecycle.svelte';
|
|
18
|
+
import { savePackage, loadBundle, loadMeta, listInstalled, removePackage } from './storage';
|
|
19
|
+
import { deactivateShard, unregisterShard } from '../shards/lifecycle.svelte';
|
|
20
20
|
import { unregisterApp } from '../apps/lifecycle';
|
|
21
21
|
import { registerLoadedBundle } from './register';
|
|
22
22
|
import { extractBundlePermissions } from './permission-descriptions';
|
|
@@ -35,6 +35,7 @@ import { fetchServerPackages } from '../env/client';
|
|
|
35
35
|
* @returns Result object indicating success/failure and hot-load status.
|
|
36
36
|
*/
|
|
37
37
|
export async function installPackage(bundle, meta, options) {
|
|
38
|
+
var _a, _b;
|
|
38
39
|
// 1. Load the module from bytes (or reuse the caller's copy).
|
|
39
40
|
// Archive integrity is verified upstream in fetchArchive() before extraction.
|
|
40
41
|
let loaded;
|
|
@@ -68,7 +69,33 @@ export async function installPackage(bundle, meta, options) {
|
|
|
68
69
|
error: `Package "${meta.id}" declared type "app" but bundle contains no valid app`,
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
|
-
// 4.
|
|
72
|
+
// 4. Compute the new bundle's contributed ids. Used both to populate the
|
|
73
|
+
// new InstalledPackage record AND to diff against the previous record
|
|
74
|
+
// so we can drop shards/apps the new bundle no longer ships.
|
|
75
|
+
const contributedShards = loaded.shards.map((s) => s.manifest.id);
|
|
76
|
+
const contributedApps = loaded.apps.map((a) => a.manifest.id);
|
|
77
|
+
// 5. Diff against the previous record (if any) and unregister anything the
|
|
78
|
+
// new bundle no longer ships. Without this, a combo update that drops
|
|
79
|
+
// an app leaves the dropped app sitting in `registeredApps` until the
|
|
80
|
+
// user clears the webview cache (Bug A).
|
|
81
|
+
//
|
|
82
|
+
// Legacy records written before `contributedShards`/`contributedApps`
|
|
83
|
+
// existed treat the missing field as the empty set — no diff is
|
|
84
|
+
// possible for that one upgrade, and the fields populate on this save.
|
|
85
|
+
const prior = await loadMeta(meta.id).catch(() => null);
|
|
86
|
+
if (prior) {
|
|
87
|
+
const newShardSet = new Set(contributedShards);
|
|
88
|
+
const newAppSet = new Set(contributedApps);
|
|
89
|
+
for (const id of (_a = prior.contributedShards) !== null && _a !== void 0 ? _a : []) {
|
|
90
|
+
if (!newShardSet.has(id))
|
|
91
|
+
unregisterShard(id);
|
|
92
|
+
}
|
|
93
|
+
for (const id of (_b = prior.contributedApps) !== null && _b !== void 0 ? _b : []) {
|
|
94
|
+
if (!newAppSet.has(id))
|
|
95
|
+
unregisterApp(id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 6. Persist to IndexedDB. Permissions captured from manifest(s).
|
|
72
99
|
const record = {
|
|
73
100
|
id: meta.id,
|
|
74
101
|
type: meta.type,
|
|
@@ -77,6 +104,8 @@ export async function installPackage(bundle, meta, options) {
|
|
|
77
104
|
contractVersion: meta.contractVersion,
|
|
78
105
|
installedAt: new Date().toISOString(),
|
|
79
106
|
permissions: extractBundlePermissions(loaded),
|
|
107
|
+
contributedShards,
|
|
108
|
+
contributedApps,
|
|
80
109
|
};
|
|
81
110
|
try {
|
|
82
111
|
await savePackage(meta.id, bundle, record);
|
|
@@ -88,15 +117,16 @@ export async function installPackage(bundle, meta, options) {
|
|
|
88
117
|
error: `Failed to persist package: ${err instanceof Error ? err.message : String(err)}`,
|
|
89
118
|
};
|
|
90
119
|
}
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
120
|
+
// 7. Evict any existing registration for the package id itself before
|
|
121
|
+
// re-registering. Step 5's diff covers ids the new bundle drops; this
|
|
122
|
+
// handles the case where the package id IS one of the new bundle's
|
|
123
|
+
// shard/app ids (single-shard or single-app packages) and we need to
|
|
124
|
+
// tear down its active state before re-registering the new module.
|
|
95
125
|
if (meta.type === 'shard' || meta.type === 'combo') {
|
|
96
126
|
try {
|
|
97
127
|
deactivateShard(meta.id);
|
|
98
128
|
}
|
|
99
|
-
catch ( /* not active or not a shard */
|
|
129
|
+
catch ( /* not active or not a shard */_c) { /* not active or not a shard */ }
|
|
100
130
|
}
|
|
101
131
|
if (meta.type === 'app' || meta.type === 'combo') {
|
|
102
132
|
unregisterApp(meta.id);
|
|
@@ -175,11 +205,21 @@ export async function loadInstalledPackages() {
|
|
|
175
205
|
await removePackage(pkg.id).catch(() => { });
|
|
176
206
|
}
|
|
177
207
|
}
|
|
178
|
-
// Load packages from server — use IndexedDB cache if available
|
|
208
|
+
// Load packages from server — use IndexedDB cache if available AND the
|
|
209
|
+
// cached version matches the server's. The server is the source of truth
|
|
210
|
+
// for installed versions: if another device updated the package, this
|
|
211
|
+
// device's cache holds a stale bundle and must refetch (Bug B). Without
|
|
212
|
+
// this, device B opens the env, sees `id` is in local cache, loads the
|
|
213
|
+
// old bytes, and never notices it's behind.
|
|
179
214
|
for (const serverPkg of serverPackages) {
|
|
180
215
|
if (localIds.has(serverPkg.id)) {
|
|
181
216
|
const localPkg = localPackages.find(p => p.id === serverPkg.id);
|
|
182
|
-
|
|
217
|
+
if (localPkg.version !== serverPkg.version) {
|
|
218
|
+
await _fetchAndCacheFromServer(serverPkg);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
await _loadFromIndexedDB(localPkg);
|
|
222
|
+
}
|
|
183
223
|
}
|
|
184
224
|
else {
|
|
185
225
|
await _fetchAndCacheFromServer(serverPkg);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import 'fake-indexeddb/auto';
|