@crowdedkingdoms/construct 0.3.11 → 0.4.1-dev.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/README.md +15 -2
- package/node/live-draft.d.mts +17 -0
- package/node/live-draft.mjs +618 -0
- package/node/live-draft.test.mjs +273 -0
- package/node/security-headers.d.mts +1 -1
- package/node/security-headers.mjs +5 -6
- package/node/vite-plugins.d.mts +11 -0
- package/node/vite-plugins.mjs +201 -5
- package/node/vite-plugins.test.mjs +51 -0
- package/package.json +11 -3
- package/src/engine/SceneRouter.test.ts +14 -0
- package/src/engine/SceneRouter.ts +10 -0
- package/src/platform/exec/worldHub.test.ts +10 -1
- package/src/platform/exec/worldHub.ts +29 -0
- package/src/platform/live/content.test.ts +139 -0
- package/src/platform/live/content.ts +284 -0
- package/src/platform/live/devServerDraftStore.ts +330 -0
- package/src/platform/live/draftLoader.test.ts +166 -0
- package/src/platform/live/draftLoader.ts +265 -0
- package/src/platform/live/draftModules.test.ts +195 -0
- package/src/platform/live/draftModules.ts +263 -0
- package/src/platform/live/draftServiceWorker.ts +176 -0
- package/src/platform/live/sdk.ts +50 -0
- package/src/platform/live/virtual.d.ts +5 -0
- package/src/platform/localLiveCode.ts +11 -0
- package/src/platform/onboarding/steps.d.mts +1 -1
- package/src/platform/onboarding/steps.mjs +43 -10
- package/src/platform/onboarding/steps.test.ts +123 -0
- package/src/platform/studio/StudioService.ts +232 -17
- package/src/platform/studio/studioQuery.test.ts +65 -0
- package/src/platform/studio/studioQuery.ts +74 -0
|
@@ -494,3 +494,126 @@ describe('onboarding steps', () => {
|
|
|
494
494
|
expect(resConnected.repo).toBe('modder/my-mod');
|
|
495
495
|
});
|
|
496
496
|
});
|
|
497
|
+
|
|
498
|
+
describe('ensureRedirectUris', () => {
|
|
499
|
+
function clientFor(app: Any): Any {
|
|
500
|
+
return {
|
|
501
|
+
apps: { app: vi.fn(async () => app) },
|
|
502
|
+
portal: { setAppClientSettings: vi.fn(async () => ({})) },
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
it('adds the missing origins and makes the first public one the launch URL when there is none', async () => {
|
|
507
|
+
const client = clientFor({ redirectUris: ['http://localhost:5175/'], launchUrl: null });
|
|
508
|
+
const result = await steps.ensureRedirectUris(client, {
|
|
509
|
+
appId: '7',
|
|
510
|
+
origins: ['http://localhost:5175', 'https://play.example.com'],
|
|
511
|
+
});
|
|
512
|
+
expect(client.portal.setAppClientSettings).toHaveBeenCalledWith({
|
|
513
|
+
appId: '7',
|
|
514
|
+
redirectUris: ['http://localhost:5175/', 'https://play.example.com/'],
|
|
515
|
+
launchUrl: 'https://play.example.com/',
|
|
516
|
+
});
|
|
517
|
+
expect(result).toEqual({
|
|
518
|
+
redirectUris: ['http://localhost:5175/', 'https://play.example.com/'],
|
|
519
|
+
added: ['https://play.example.com'],
|
|
520
|
+
launchUrl: 'https://play.example.com/',
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('sets the launch URL even when the origin is already registered', async () => {
|
|
525
|
+
const client = clientFor({ redirectUris: ['https://play.example.com/'], launchUrl: null });
|
|
526
|
+
await steps.ensureRedirectUris(client, { appId: '7', origins: ['https://play.example.com'] });
|
|
527
|
+
expect(client.portal.setAppClientSettings).toHaveBeenCalledWith({
|
|
528
|
+
appId: '7',
|
|
529
|
+
redirectUris: ['https://play.example.com/'],
|
|
530
|
+
launchUrl: 'https://play.example.com/',
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('leaves a launch URL the app already has, and never makes loopback the launch URL', async () => {
|
|
535
|
+
const kept = clientFor({ redirectUris: [], launchUrl: 'https://mine.example/' });
|
|
536
|
+
await steps.ensureRedirectUris(kept, { appId: '7', origins: ['https://play.example.com'] });
|
|
537
|
+
expect(kept.portal.setAppClientSettings).toHaveBeenCalledWith({
|
|
538
|
+
appId: '7',
|
|
539
|
+
redirectUris: ['https://play.example.com/'],
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const local = clientFor({ redirectUris: [], launchUrl: null });
|
|
543
|
+
const result = await steps.ensureRedirectUris(local, {
|
|
544
|
+
appId: '7',
|
|
545
|
+
origins: ['http://localhost:5175', 'http://127.0.0.1:4173'],
|
|
546
|
+
});
|
|
547
|
+
expect(local.portal.setAppClientSettings).toHaveBeenCalledWith({
|
|
548
|
+
appId: '7',
|
|
549
|
+
redirectUris: ['http://localhost:5175/', 'http://127.0.0.1:4173/'],
|
|
550
|
+
});
|
|
551
|
+
expect(result.launchUrl).toBeNull();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('writes nothing when every origin is registered and the launch URL is set', async () => {
|
|
555
|
+
const client = clientFor({
|
|
556
|
+
redirectUris: ['https://play.example.com/'],
|
|
557
|
+
launchUrl: 'https://play.example.com/',
|
|
558
|
+
});
|
|
559
|
+
const result = await steps.ensureRedirectUris(client, {
|
|
560
|
+
appId: '7',
|
|
561
|
+
origins: ['https://play.example.com'],
|
|
562
|
+
});
|
|
563
|
+
expect(client.portal.setAppClientSettings).not.toHaveBeenCalled();
|
|
564
|
+
expect(result.added).toEqual([]);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe('ensureAgentPolicy funding note', () => {
|
|
569
|
+
const ready = (funding: Any) => ({
|
|
570
|
+
enabled: true,
|
|
571
|
+
killSwitch: false,
|
|
572
|
+
allowedModelIds: [steps.STUDIO_AGENT_MODEL],
|
|
573
|
+
allowedModes: [...steps.STUDIO_AGENT_MODES],
|
|
574
|
+
funding,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('says who pays when the tier meters model use', async () => {
|
|
578
|
+
const lines: string[] = [];
|
|
579
|
+
const policy = ready({ billingMode: 'METERED', payerKind: 'PLAYER', walletDebitEnabled: true });
|
|
580
|
+
const query = vi.fn(async () => ({
|
|
581
|
+
crowdyStudioAgentPolicy: policy,
|
|
582
|
+
crowdyStudioAgentEffectivePolicy: policy,
|
|
583
|
+
}));
|
|
584
|
+
await steps.ensureAgentPolicy({ graphql: { query } } as never, { appId: '77' }, (line) =>
|
|
585
|
+
lines.push(line),
|
|
586
|
+
);
|
|
587
|
+
expect(lines.join('\n')).toMatch(/metered: model use is charged to each player's own wallet/);
|
|
588
|
+
|
|
589
|
+
const orgLines: string[] = [];
|
|
590
|
+
const orgPolicy = ready({ billingMode: 'METERED', payerKind: 'ORG', walletDebitEnabled: true });
|
|
591
|
+
const orgQuery = vi.fn(async () => ({
|
|
592
|
+
crowdyStudioAgentPolicy: orgPolicy,
|
|
593
|
+
crowdyStudioAgentEffectivePolicy: orgPolicy,
|
|
594
|
+
}));
|
|
595
|
+
await steps.ensureAgentPolicy(
|
|
596
|
+
{ graphql: { query: orgQuery } } as never,
|
|
597
|
+
{ appId: '77' },
|
|
598
|
+
(line) => orgLines.push(line),
|
|
599
|
+
);
|
|
600
|
+
expect(orgLines.join('\n')).toMatch(/the app's org wallet/);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('says platform-funded only when the tier does not meter', async () => {
|
|
604
|
+
const lines: string[] = [];
|
|
605
|
+
const policy = ready({
|
|
606
|
+
billingMode: 'PLATFORM_FUNDED',
|
|
607
|
+
payerKind: 'PLATFORM',
|
|
608
|
+
walletDebitEnabled: false,
|
|
609
|
+
});
|
|
610
|
+
const query = vi.fn(async () => ({
|
|
611
|
+
crowdyStudioAgentPolicy: policy,
|
|
612
|
+
crowdyStudioAgentEffectivePolicy: policy,
|
|
613
|
+
}));
|
|
614
|
+
await steps.ensureAgentPolicy({ graphql: { query } } as never, { appId: '77' }, (line) =>
|
|
615
|
+
lines.push(line),
|
|
616
|
+
);
|
|
617
|
+
expect(lines.join('\n')).toMatch(/already enabled \(platform-funded\)/);
|
|
618
|
+
});
|
|
619
|
+
});
|
|
@@ -44,6 +44,10 @@ import { messageOf } from '../network/NetworkManager';
|
|
|
44
44
|
import { isTextEntry } from '../../engine/Input';
|
|
45
45
|
import { chunkKey, worldToChunk, type ChunkCoord, type Vec3 } from '../realtime/space';
|
|
46
46
|
import { AgentLocomotion } from './agentLocomotion';
|
|
47
|
+
import { wantsStudioOpen } from './studioQuery';
|
|
48
|
+
import type { GameContentHost, PreviewStatus } from '../live/content';
|
|
49
|
+
import { DevServerDraftStore, devServerDraftToken } from '../live/devServerDraftStore';
|
|
50
|
+
import { liveCodeSdk, type DraftProject, type DraftStore } from '../live/sdk';
|
|
47
51
|
import { describeClientHalfPrompt, GridClientHalves, studioPreviewModName } from './clientHalves';
|
|
48
52
|
import { ConstructPlayerHostAdapter } from './ConstructPlayerHostAdapter';
|
|
49
53
|
import { HumanInputMonitor } from './HumanInputMonitor';
|
|
@@ -119,6 +123,26 @@ export interface StudioHooks {
|
|
|
119
123
|
/** Screenshot hook for the agent pane. */
|
|
120
124
|
captureFrame?(): Promise<HTMLCanvasElement | ImageBitmap | Blob | null>;
|
|
121
125
|
describeView?(): string | undefined;
|
|
126
|
+
/**
|
|
127
|
+
* The game's content layer. With it, the game editor previews the draft in
|
|
128
|
+
* the running world (Vite HMR on a dev server, the draft service worker on
|
|
129
|
+
* a hosted page) and answers the agent's `preview_status`.
|
|
130
|
+
*/
|
|
131
|
+
content?: GameContentHost;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The context the game editor opens in when no grid is open (mods cannot be built from it). */
|
|
135
|
+
const GAME_EDITOR_GRID = 'game-editor';
|
|
136
|
+
|
|
137
|
+
function baseUrl(): string {
|
|
138
|
+
return import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A Vite error's file, as a repository path (the dev server's root is not known here). */
|
|
142
|
+
function repoPathOf(file: string | undefined): string | undefined {
|
|
143
|
+
if (!file) return undefined;
|
|
144
|
+
const match = /(?:^|\/)((?:src|model|packages)\/.+)$/.exec(file.replace(/\\/g, '/'));
|
|
145
|
+
return match?.[1];
|
|
122
146
|
}
|
|
123
147
|
|
|
124
148
|
/**
|
|
@@ -293,6 +317,24 @@ export class StudioService {
|
|
|
293
317
|
attach(hooks: StudioHooks): void {
|
|
294
318
|
this.hooks = hooks;
|
|
295
319
|
const network = () => this.session.network;
|
|
320
|
+
const dsh = {
|
|
321
|
+
graphql: network().game.graphql,
|
|
322
|
+
// Under Vite's base, so a build served from a sub-path (`vite build
|
|
323
|
+
// --base /the-construct/`) finds its own harness instead of the
|
|
324
|
+
// origin's root. The default base is '/', which keeps '/dsh/'.
|
|
325
|
+
webBase: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}dsh/`,
|
|
326
|
+
graphqlUrl: `${API_HTTP_URL}/graphql`,
|
|
327
|
+
apiOrigin: API_HTTP_URL,
|
|
328
|
+
getToken: () => network().game.getToken(),
|
|
329
|
+
persistScope: `${this.session.appId}/${this.session.selfUuid}`,
|
|
330
|
+
studioOrigin: STUDIO_ORIGIN ?? undefined,
|
|
331
|
+
openOnMount: wantsStudioOpen(globalThis.location?.search ?? ''),
|
|
332
|
+
};
|
|
333
|
+
// The game editor (CrowdyJS 18.23+), for a project bound to a Construct
|
|
334
|
+
// game the player owns: the controller asks for the draft when it opens one.
|
|
335
|
+
const gameEditor: { draftStore?: (project: DraftProject) => DraftStore | null } = hooks.content
|
|
336
|
+
? { draftStore: (project) => this.draftStoreFor(project) }
|
|
337
|
+
: {};
|
|
296
338
|
this.embed = new CrowdyStudioEmbed({
|
|
297
339
|
// Resolved lazily: the game client exists only after enterApp, and is
|
|
298
340
|
// rebuilt if the player switches apps.
|
|
@@ -323,19 +365,8 @@ export class StudioService {
|
|
|
323
365
|
appId: () => this.session.appId,
|
|
324
366
|
gameName: GAME_NAME,
|
|
325
367
|
closeKeyCode: 'KeyM',
|
|
326
|
-
dsh
|
|
327
|
-
|
|
328
|
-
// Under Vite's base, so a build served from a sub-path (`vite build
|
|
329
|
-
// --base /the-construct/`) finds its own harness instead of the
|
|
330
|
-
// origin's root. The default base is '/', which keeps '/dsh/'.
|
|
331
|
-
webBase: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}dsh/`,
|
|
332
|
-
graphqlUrl: `${API_HTTP_URL}/graphql`,
|
|
333
|
-
apiOrigin: API_HTTP_URL,
|
|
334
|
-
getToken: () => network().game.getToken(),
|
|
335
|
-
persistScope: `${this.session.appId}/${this.session.selfUuid}`,
|
|
336
|
-
studioOrigin: STUDIO_ORIGIN ?? undefined,
|
|
337
|
-
openOnMount: true,
|
|
338
|
-
},
|
|
368
|
+
dsh,
|
|
369
|
+
...gameEditor,
|
|
339
370
|
onAgentMounted: (handle) => {
|
|
340
371
|
this.watchStudio(handle.controller);
|
|
341
372
|
this.state = { ...this.state, agentReady: true, agentReason: null };
|
|
@@ -360,6 +391,7 @@ export class StudioService {
|
|
|
360
391
|
this.hud.remove(source);
|
|
361
392
|
}
|
|
362
393
|
this.studioGridId = null;
|
|
394
|
+
this.stopPreview();
|
|
363
395
|
this.setOpen(false);
|
|
364
396
|
void this.refreshClientHalves();
|
|
365
397
|
},
|
|
@@ -377,6 +409,25 @@ export class StudioService {
|
|
|
377
409
|
return this.embed?.open ?? false;
|
|
378
410
|
}
|
|
379
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Open Crowdy Studio on `projectId` (Crowdy Studio's Live code link names
|
|
414
|
+
* one): on the grid underfoot when the player may edit it, otherwise in the
|
|
415
|
+
* game editor alone. A project bound to a Construct game opens its editor
|
|
416
|
+
* either way; the controller decides from the repository.
|
|
417
|
+
*/
|
|
418
|
+
openProject(projectId: string): void {
|
|
419
|
+
if (!this.embed) return;
|
|
420
|
+
if (this.embed.open) this.embed.close();
|
|
421
|
+
const grid =
|
|
422
|
+
this.currentGrid && hasAnyStudioPermission(this.currentGrid.permissions)
|
|
423
|
+
? this.currentGrid
|
|
424
|
+
: null;
|
|
425
|
+
const opened: { projectId: string } = { projectId };
|
|
426
|
+
this.embed.toggle({ ...(grid ? this.contextFor(grid) : this.gameEditorContext()), ...opened });
|
|
427
|
+
this.studioGridId = grid && this.embed.open ? grid.gridId : null;
|
|
428
|
+
this.setOpen(true);
|
|
429
|
+
}
|
|
430
|
+
|
|
380
431
|
/** Claim the chunk under `position`, then open the studio on it. */
|
|
381
432
|
async claimHereAndOpen(position: Vec3): Promise<GridSnapshot> {
|
|
382
433
|
const chunk = worldToChunk(position);
|
|
@@ -462,6 +513,7 @@ export class StudioService {
|
|
|
462
513
|
}
|
|
463
514
|
|
|
464
515
|
dispose(): void {
|
|
516
|
+
this.stopPreview();
|
|
465
517
|
if (this.timer) clearInterval(this.timer);
|
|
466
518
|
this.timer = null;
|
|
467
519
|
this.halves?.stop();
|
|
@@ -539,12 +591,175 @@ export class StudioService {
|
|
|
539
591
|
}
|
|
540
592
|
: {}),
|
|
541
593
|
playerHost: this.agentHost,
|
|
542
|
-
dshHost:
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
594
|
+
dshHost: this.dshHost(),
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The game editor without a grid: no mod can be built or run from here. */
|
|
599
|
+
private gameEditorContext(): CrowdyStudioEmbedContext {
|
|
600
|
+
const none = { canWrite: false, canRun: false };
|
|
601
|
+
return {
|
|
602
|
+
gridId: GAME_EDITOR_GRID,
|
|
603
|
+
targetPermissions: { SERVER: none, CLIENT: none },
|
|
604
|
+
permissionsNote:
|
|
605
|
+
'Game editor: no grid is open. Stand on your claimed chunk and press M to build mods.',
|
|
606
|
+
playerHost: this.agentHost,
|
|
607
|
+
dshHost: this.dshHost(),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private dshHost(): NonNullable<CrowdyStudioEmbedContext['dshHost']> {
|
|
612
|
+
// CrowdyJS 18.23 reads these (the playbook, live-modding checks, the
|
|
613
|
+
// preview status); the template's SDK pin may not list them yet.
|
|
614
|
+
const extras: {
|
|
615
|
+
playbookBase: string;
|
|
616
|
+
authorizeLiveModding(action: 'draft_test' | 'deploy_live'): Promise<void>;
|
|
617
|
+
previewStatus?(params: {
|
|
618
|
+
afterRevision?: number;
|
|
619
|
+
timeoutMs?: number;
|
|
620
|
+
}): Promise<PreviewStatus>;
|
|
621
|
+
} = {
|
|
622
|
+
playbookBase: `${baseUrl()}playbook/`,
|
|
623
|
+
authorizeLiveModding: async (action) => {
|
|
624
|
+
await this.session.network.game.graphql.query(
|
|
625
|
+
`query CrowdyStudioLiveModding($appId: BigInt!, $action: String!) {
|
|
626
|
+
crowdyStudioLiveModding(appId: $appId, action: $action)
|
|
627
|
+
}`,
|
|
628
|
+
{ appId: this.session.appId, action },
|
|
629
|
+
);
|
|
546
630
|
},
|
|
631
|
+
...(this.hooks?.content
|
|
632
|
+
? { previewStatus: (params) => this.hooks!.content!.status(params) }
|
|
633
|
+
: {}),
|
|
547
634
|
};
|
|
635
|
+
return {
|
|
636
|
+
captureFrame: async () => (await this.hooks?.captureFrame?.()) ?? null,
|
|
637
|
+
describeView: () => this.hooks?.describeView?.(),
|
|
638
|
+
grid: this.programs,
|
|
639
|
+
...extras,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The draft of a game project: the dev server's checkout when this page is
|
|
645
|
+
* served by one with the live draft on, otherwise the hosted store (an
|
|
646
|
+
* overlay over GitHub in this browser). Starts the matching preview.
|
|
647
|
+
*/
|
|
648
|
+
private draftStoreFor(project: DraftProject): DraftStore | null {
|
|
649
|
+
const sdk = liveCodeSdk();
|
|
650
|
+
if (!sdk) {
|
|
651
|
+
this.hooks?.notify(
|
|
652
|
+
'This game’s CrowdyJS predates the game editor; the project opens in the mod editor.',
|
|
653
|
+
'warn',
|
|
654
|
+
);
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
const transport = this.session.network.game.crowdyStudioGitHub;
|
|
658
|
+
const token = import.meta.env.DEV ? devServerDraftToken() : null;
|
|
659
|
+
const store: DraftStore = token
|
|
660
|
+
? new DevServerDraftStore({
|
|
661
|
+
base: baseUrl(),
|
|
662
|
+
token,
|
|
663
|
+
appId: project.appId,
|
|
664
|
+
projectId: project.projectId,
|
|
665
|
+
transport,
|
|
666
|
+
sdk,
|
|
667
|
+
...(import.meta.hot ? { hot: import.meta.hot } : {}),
|
|
668
|
+
})
|
|
669
|
+
: new sdk.GitHubDraftStore({
|
|
670
|
+
transport,
|
|
671
|
+
appId: project.appId,
|
|
672
|
+
projectId: project.projectId,
|
|
673
|
+
baseCommitSha: project.githubSha,
|
|
674
|
+
storage: new sdk.IndexedDbDraftOverlayStorage(),
|
|
675
|
+
});
|
|
676
|
+
this.startPreview(store, token ? 'hmr' : 'service-worker');
|
|
677
|
+
return store;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private preview: { dispose(): void } | null = null;
|
|
681
|
+
|
|
682
|
+
private startPreview(store: DraftStore, mode: 'hmr' | 'service-worker'): void {
|
|
683
|
+
this.stopPreview();
|
|
684
|
+
const content = this.hooks?.content;
|
|
685
|
+
if (!content) return;
|
|
686
|
+
if (mode === 'hmr') {
|
|
687
|
+
// main.ts's accept boundary swaps the content; this records what it did not see.
|
|
688
|
+
const hot = import.meta.hot;
|
|
689
|
+
const off = store.subscribe((event) => content.noteRevision(event.revision));
|
|
690
|
+
const onPreview = (data: {
|
|
691
|
+
path: string;
|
|
692
|
+
kind: 'not-loaded' | 'needs-reload';
|
|
693
|
+
revision: number;
|
|
694
|
+
}) => {
|
|
695
|
+
content.settleUnloaded(
|
|
696
|
+
data.revision,
|
|
697
|
+
data.kind === 'needs-reload'
|
|
698
|
+
? `${data.path} is outside the content layer (src/game/content.ts and what it imports): reload the page to see it.`
|
|
699
|
+
: `${data.path} is not part of the running game; nothing to reload.`,
|
|
700
|
+
);
|
|
701
|
+
};
|
|
702
|
+
const onError = (payload: {
|
|
703
|
+
err: {
|
|
704
|
+
message: string;
|
|
705
|
+
loc?: { file?: string; line?: number; column?: number };
|
|
706
|
+
id?: string;
|
|
707
|
+
};
|
|
708
|
+
}) => {
|
|
709
|
+
const path = repoPathOf(payload.err.loc?.file ?? payload.err.id);
|
|
710
|
+
content.fail([
|
|
711
|
+
{
|
|
712
|
+
...(path ? { path } : {}),
|
|
713
|
+
...(payload.err.loc?.line ? { line: payload.err.loc.line } : {}),
|
|
714
|
+
...(payload.err.loc?.column ? { column: payload.err.loc.column } : {}),
|
|
715
|
+
message: payload.err.message,
|
|
716
|
+
},
|
|
717
|
+
]);
|
|
718
|
+
};
|
|
719
|
+
hot?.on('construct:preview', onPreview);
|
|
720
|
+
hot?.on('vite:error', onError);
|
|
721
|
+
this.preview = {
|
|
722
|
+
dispose: () => {
|
|
723
|
+
off();
|
|
724
|
+
hot?.off?.('construct:preview', onPreview);
|
|
725
|
+
hot?.off?.('vite:error', onError);
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
let loader: { dispose(): void } | null = null;
|
|
731
|
+
let stopped = false;
|
|
732
|
+
this.preview = {
|
|
733
|
+
dispose: () => {
|
|
734
|
+
stopped = true;
|
|
735
|
+
loader?.dispose();
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
void import('../live/draftLoader')
|
|
739
|
+
.then(({ DraftLoader }) =>
|
|
740
|
+
DraftLoader.start({
|
|
741
|
+
base: baseUrl(),
|
|
742
|
+
store,
|
|
743
|
+
content,
|
|
744
|
+
env: { ...import.meta.env },
|
|
745
|
+
log: (line) => this.session.network.log(line),
|
|
746
|
+
}),
|
|
747
|
+
)
|
|
748
|
+
.then(
|
|
749
|
+
(started) => {
|
|
750
|
+
if (stopped) started.dispose();
|
|
751
|
+
else loader = started;
|
|
752
|
+
},
|
|
753
|
+
(error: unknown) => {
|
|
754
|
+
content.fail([{ message: messageOf(error) }]);
|
|
755
|
+
this.hooks?.notify(`The draft preview could not start: ${messageOf(error)}`, 'warn');
|
|
756
|
+
},
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private stopPreview(): void {
|
|
761
|
+
this.preview?.dispose();
|
|
762
|
+
this.preview = null;
|
|
548
763
|
}
|
|
549
764
|
|
|
550
765
|
/**
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { envScopedKey } from '../envScope';
|
|
3
|
+
import { rememberRequestedProject, requestedProject, wantsStudioOpen } from './studioQuery';
|
|
4
|
+
|
|
5
|
+
const KEPT = envScopedKey('construct:requested-project');
|
|
6
|
+
|
|
7
|
+
describe('wantsStudioOpen', () => {
|
|
8
|
+
it('opens for the live-code query and for a page that says nothing', () => {
|
|
9
|
+
expect(wantsStudioOpen('?app=1&studio=open')).toBe(true);
|
|
10
|
+
expect(wantsStudioOpen('')).toBe(true);
|
|
11
|
+
expect(wantsStudioOpen('?studio=closed')).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('requestedProject', () => {
|
|
16
|
+
beforeEach(() => sessionStorage.clear());
|
|
17
|
+
|
|
18
|
+
it('reads an id-shaped project and ignores anything else', () => {
|
|
19
|
+
expect(requestedProject('?app=1&studio=open&project=129481')).toBe('129481');
|
|
20
|
+
expect(requestedProject('?project=')).toBeNull();
|
|
21
|
+
expect(requestedProject('?project=../../etc')).toBeNull();
|
|
22
|
+
expect(requestedProject('')).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('opens the remembered project once after a sign-in returns to the bare address', () => {
|
|
26
|
+
rememberRequestedProject('?app=1&studio=open&project=129481');
|
|
27
|
+
// The sign-in return: the address carries only the code and state.
|
|
28
|
+
rememberRequestedProject('?code=c&state=s');
|
|
29
|
+
expect(requestedProject('?code=c&state=s')).toBe('129481');
|
|
30
|
+
expect(requestedProject('')).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('prefers the address to a remembered project, and uses the remembered one up', () => {
|
|
34
|
+
rememberRequestedProject('?project=older');
|
|
35
|
+
expect(requestedProject('?project=newer')).toBe('newer');
|
|
36
|
+
expect(sessionStorage.getItem(KEPT)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('keeps only an id-shaped project, and never opens a tampered one', () => {
|
|
40
|
+
rememberRequestedProject('?project=..%2F..%2Fetc');
|
|
41
|
+
rememberRequestedProject('?project=');
|
|
42
|
+
expect(sessionStorage.getItem(KEPT)).toBeNull();
|
|
43
|
+
sessionStorage.setItem(KEPT, '<img src=x>');
|
|
44
|
+
expect(requestedProject('')).toBeNull();
|
|
45
|
+
expect(sessionStorage.getItem(KEPT)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('carries on without storage', () => {
|
|
49
|
+
const denied = {
|
|
50
|
+
getItem: () => {
|
|
51
|
+
throw new Error('denied');
|
|
52
|
+
},
|
|
53
|
+
setItem: () => {
|
|
54
|
+
throw new Error('denied');
|
|
55
|
+
},
|
|
56
|
+
removeItem: () => {
|
|
57
|
+
throw new Error('denied');
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
expect(() => rememberRequestedProject('?project=129481', denied)).not.toThrow();
|
|
61
|
+
expect(requestedProject('?project=129481', denied)).toBe('129481');
|
|
62
|
+
expect(requestedProject('', denied)).toBeNull();
|
|
63
|
+
expect(requestedProject('?project=129481', undefined)).toBe('129481');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { envScopedKey } from '../envScope';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether the dock should open when the page loads.
|
|
5
|
+
*
|
|
6
|
+
* `studio=open` asks for it. No `studio` parameter keeps today's behavior,
|
|
7
|
+
* which is open. Any other value leaves it closed.
|
|
8
|
+
*/
|
|
9
|
+
export function wantsStudioOpen(search: string): boolean {
|
|
10
|
+
const value = new URLSearchParams(search.replace(/^\?/, '')).get('studio');
|
|
11
|
+
return value === null || value === 'open';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const PROJECT_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
15
|
+
const REMEMBERED_PROJECT_KEY = 'construct:requested-project';
|
|
16
|
+
|
|
17
|
+
type TabStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
18
|
+
|
|
19
|
+
function tabStorage(): TabStorage | undefined {
|
|
20
|
+
try {
|
|
21
|
+
return globalThis.sessionStorage;
|
|
22
|
+
} catch {
|
|
23
|
+
// A frame denied storage throws on the read itself.
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function projectIn(search: string): string | null {
|
|
29
|
+
const value = new URLSearchParams(search.replace(/^\?/, '')).get('project')?.trim();
|
|
30
|
+
return value && PROJECT_PATTERN.test(value) ? value : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Keep the project the page was asked to open for the rest of this tab's
|
|
35
|
+
* session. A hosted sign-in returns to the bare page address, without the
|
|
36
|
+
* query that named it, so call this before anything can send the page there.
|
|
37
|
+
*/
|
|
38
|
+
export function rememberRequestedProject(
|
|
39
|
+
search: string,
|
|
40
|
+
storage: TabStorage | undefined = tabStorage(),
|
|
41
|
+
): void {
|
|
42
|
+
const project = projectIn(search);
|
|
43
|
+
if (!project) return;
|
|
44
|
+
try {
|
|
45
|
+
storage?.setItem(envScopedKey(REMEMBERED_PROJECT_KEY), project);
|
|
46
|
+
} catch {
|
|
47
|
+
// Storage may be unavailable; after a sign-in the editor then opens no project.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function takeRemembered(storage: TabStorage | undefined): string | null {
|
|
52
|
+
const key = envScopedKey(REMEMBERED_PROJECT_KEY);
|
|
53
|
+
try {
|
|
54
|
+
const kept = storage?.getItem(key) ?? null;
|
|
55
|
+
storage?.removeItem(key);
|
|
56
|
+
return kept !== null && PROJECT_PATTERN.test(kept) ? kept : null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The Studio project the page was asked to open (`project=<id>`, from Crowdy
|
|
64
|
+
* Studio's Live code link), or null. Only an id-shaped value counts. Without
|
|
65
|
+
* one on the address, the one `rememberRequestedProject` kept before a sign-in.
|
|
66
|
+
* Either way the kept one is used up, so it opens once.
|
|
67
|
+
*/
|
|
68
|
+
export function requestedProject(
|
|
69
|
+
search: string,
|
|
70
|
+
storage: TabStorage | undefined = tabStorage(),
|
|
71
|
+
): string | null {
|
|
72
|
+
const kept = takeRemembered(storage);
|
|
73
|
+
return projectIn(search) ?? kept;
|
|
74
|
+
}
|