@v12-engine/create 0.1.1 → 0.4.0
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 +4 -1
- package/base/_gitignore +5 -0
- package/base/package.json +3 -3
- package/base/src/main.ts +36 -10
- package/base/src/navigation.worker.ts +7 -0
- package/base/vite.config.ts +24 -2
- package/cli.mjs +21 -6
- package/index.mjs +27 -7
- package/layers/desktop/electron/app.mjs +237 -0
- package/layers/desktop/electron/config.mjs +16 -0
- package/layers/desktop/electron/dev.mjs +63 -0
- package/layers/desktop/electron/files.mjs +86 -0
- package/layers/desktop/electron/icons/icon.icns +0 -0
- package/layers/desktop/electron/icons/icon.png +0 -0
- package/layers/desktop/electron/main.mjs +12 -0
- package/layers/desktop/electron/package.mjs +75 -0
- package/layers/desktop/electron/preload.cjs +41 -0
- package/layers/desktop/electron/presentation.m +87 -0
- package/layers/desktop/electron/serve.mjs +76 -0
- package/layers/desktop/electron/tools.mjs +63 -0
- package/layers/desktop/electron/updates.mjs +71 -0
- package/layers/desktop/package.json +13 -0
- package/layers/desktop/src/desktop.ts +175 -0
- package/layers/desktop/src/shell.ts +279 -0
- package/layers/desktop/tests/main.test.ts +189 -0
- package/layers/desktop/tests/shell.test.ts +152 -0
- package/layers/rooms/server.ts +13 -10
- package/layers/server/server.ts +4 -5
- package/layers/source/vite.config.ts +21 -1
- package/package.json +1 -1
- package/starters/campaign/README.md +9 -2
- package/starters/campaign/layers/rooms/server.ts +3 -3
- package/starters/campaign/layers/server/server.ts +3 -5
- package/starters/campaign/layers/tests/tests/rules.test.ts +40 -21
- package/starters/campaign/src/campaign/game.ts +26 -5
- package/starters/campaign/src/campaign/index.ts +104 -68
- package/starters/campaign/src/campaign/kills.ts +1 -1
- package/starters/campaign/src/campaign/missions/homecoming.ts +7 -7
- package/starters/campaign/src/campaign/missions/siege.ts +6 -6
- package/starters/campaign/src/campaign/missions/warlord.ts +13 -14
- package/starters/campaign/src/campaign/scenes.ts +8 -7
- package/starters/campaign/src/campaign/units.ts +59 -20
- package/starters/campaign/src/campaign/view/attacks.ts +19 -0
- package/starters/campaign/src/campaign/view/fire.ts +2 -2
- package/starters/campaign/src/campaign/view/index.ts +54 -43
- package/starters/campaign/src/campaign/waves.ts +18 -17
- package/starters/campaign/src/game.ts +8 -1
- package/starters/campaign/src/main.ts +38 -19
- package/starters/campaign/src/maps/fort.names.ts +2 -2
- package/starters/campaign/src/maps/fort.vgmap.json +102 -2
- package/starters/campaign/src/maps/homestead.names.ts +2 -2
- package/starters/campaign/src/maps/homestead.vgmap.json +85 -1
- package/starters/campaign/src/maps/pass.names.ts +2 -2
- package/starters/campaign/src/maps/pass.vgmap.json +69 -1
- package/starters/campaign/src/waves-panel.ts +2 -1
- package/starters/empty/README.md +18 -10
- package/starters/empty/layers/tests/tests/rules.test.ts +16 -17
- package/starters/empty/src/game.ts +11 -75
- package/starters/empty/src/generate.worker.ts +5 -5
- package/starters/empty/src/rules.ts +83 -25
- package/starters/empty/src/view.ts +82 -0
- package/starters/examples/README.md +83 -0
- package/starters/examples/layers/tests/tests/examples.test.ts +30 -0
- package/starters/examples/package.json +5 -0
- package/starters/examples/src/examples/abilities/areas.ts +165 -0
- package/starters/examples/src/examples/abilities/areasLooks.ts +30 -0
- package/starters/examples/src/examples/abilities/buffs.ts +287 -0
- package/starters/examples/src/examples/abilities/buffsLooks.ts +46 -0
- package/starters/examples/src/examples/abilities/changing.ts +228 -0
- package/starters/examples/src/examples/abilities/changingLooks.ts +61 -0
- package/starters/examples/src/examples/abilities/control.ts +158 -0
- package/starters/examples/src/examples/abilities/controlLooks.ts +78 -0
- package/starters/examples/src/examples/abilities/damage.ts +156 -0
- package/starters/examples/src/examples/abilities/damageLooks.ts +37 -0
- package/starters/examples/src/examples/abilities/family.ts +29 -0
- package/starters/examples/src/examples/abilities/index.ts +32 -0
- package/starters/examples/src/examples/abilities/kit.ts +179 -0
- package/starters/examples/src/examples/abilities/lookKit.ts +71 -0
- package/starters/examples/src/examples/abilities/looks.ts +47 -0
- package/starters/examples/src/examples/abilities/missiles.ts +203 -0
- package/starters/examples/src/examples/abilities/missilesLooks.ts +38 -0
- package/starters/examples/src/examples/abilities/moves.ts +206 -0
- package/starters/examples/src/examples/abilities/movesLooks.ts +52 -0
- package/starters/examples/src/examples/abilities/recasts.ts +181 -0
- package/starters/examples/src/examples/abilities/recastsLooks.ts +41 -0
- package/starters/examples/src/examples/abilities/resources.ts +166 -0
- package/starters/examples/src/examples/abilities/resourcesLooks.ts +42 -0
- package/starters/examples/src/examples/abilities/summons.ts +296 -0
- package/starters/examples/src/examples/abilities/summonsLooks.ts +115 -0
- package/starters/examples/src/examples/abilities/triggers.ts +204 -0
- package/starters/examples/src/examples/abilities/triggersLooks.ts +53 -0
- package/starters/examples/src/examples/abilities/vision.ts +137 -0
- package/starters/examples/src/examples/abilities/visionLooks.ts +50 -0
- package/starters/examples/src/examples/animation/index.ts +235 -0
- package/starters/examples/src/examples/animation/looks.ts +444 -0
- package/starters/examples/src/examples/catalog.ts +52 -0
- package/starters/examples/src/examples/cinematics/index.ts +217 -0
- package/starters/examples/src/examples/cinematics/looks.ts +476 -0
- package/starters/examples/src/examples/cinematics/tour.json +43 -0
- package/starters/examples/src/examples/cinematics/village.ts +88 -0
- package/starters/examples/src/examples/common/looks.ts +65 -0
- package/starters/examples/src/examples/common/units.ts +70 -0
- package/starters/examples/src/examples/construction/index.ts +329 -0
- package/starters/examples/src/examples/construction/looks.ts +275 -0
- package/starters/examples/src/examples/custom-animation/bones.ts +92 -0
- package/starters/examples/src/examples/custom-animation/index.ts +190 -0
- package/starters/examples/src/examples/custom-animation/looks.ts +523 -0
- package/starters/examples/src/examples/custom-animation/runes.ts +82 -0
- package/starters/examples/src/examples/custom-fx/glaive.look.ts +110 -0
- package/starters/examples/src/examples/custom-fx/glaive.ts +40 -0
- package/starters/examples/src/examples/custom-fx/gpu.look.ts +145 -0
- package/starters/examples/src/examples/custom-fx/gpu.ts +22 -0
- package/starters/examples/src/examples/custom-fx/index.ts +25 -0
- package/starters/examples/src/examples/custom-fx/lights.look.ts +144 -0
- package/starters/examples/src/examples/custom-fx/lights.ts +25 -0
- package/starters/examples/src/examples/custom-fx/looks.ts +36 -0
- package/starters/examples/src/examples/custom-fx/meteor.look.ts +133 -0
- package/starters/examples/src/examples/custom-fx/meteor.ts +26 -0
- package/starters/examples/src/examples/custom-fx/parts.look.ts +193 -0
- package/starters/examples/src/examples/custom-fx/parts.ts +21 -0
- package/starters/examples/src/examples/custom-fx/portal.look.ts +193 -0
- package/starters/examples/src/examples/custom-fx/portal.ts +48 -0
- package/starters/examples/src/examples/custom-fx/renderer.look.ts +139 -0
- package/starters/examples/src/examples/custom-fx/renderer.ts +18 -0
- package/starters/examples/src/examples/custom-fx/ribbons.look.ts +151 -0
- package/starters/examples/src/examples/custom-fx/ribbons.ts +22 -0
- package/starters/examples/src/examples/custom-fx/shader.ts +42 -0
- package/starters/examples/src/examples/custom-fx/shield.look.ts +226 -0
- package/starters/examples/src/examples/custom-fx/shield.ts +43 -0
- package/starters/examples/src/examples/define.ts +86 -0
- package/starters/examples/src/examples/destruction/index.ts +570 -0
- package/starters/examples/src/examples/destruction/looks.ts +447 -0
- package/starters/examples/src/examples/dialogs/index.ts +383 -0
- package/starters/examples/src/examples/dialogs/looks.ts +546 -0
- package/starters/examples/src/examples/dynamic-map/index.ts +603 -0
- package/starters/examples/src/examples/dynamic-map/looks.ts +301 -0
- package/starters/examples/src/examples/fx/blood.look.ts +62 -0
- package/starters/examples/src/examples/fx/blood.ts +59 -0
- package/starters/examples/src/examples/fx/feel.look.ts +86 -0
- package/starters/examples/src/examples/fx/feel.ts +57 -0
- package/starters/examples/src/examples/fx/fight.ts +28 -0
- package/starters/examples/src/examples/fx/floats.look.ts +108 -0
- package/starters/examples/src/examples/fx/floats.ts +24 -0
- package/starters/examples/src/examples/fx/gallery.look.ts +168 -0
- package/starters/examples/src/examples/fx/gallery.ts +23 -0
- package/starters/examples/src/examples/fx/index.ts +30 -0
- package/starters/examples/src/examples/fx/links.look.ts +83 -0
- package/starters/examples/src/examples/fx/links.ts +49 -0
- package/starters/examples/src/examples/fx/looks.ts +55 -0
- package/starters/examples/src/examples/fx/map.look.ts +93 -0
- package/starters/examples/src/examples/fx/map.ts +33 -0
- package/starters/examples/src/examples/fx/marks.look.ts +100 -0
- package/starters/examples/src/examples/fx/marks.ts +36 -0
- package/starters/examples/src/examples/fx/shots.look.ts +92 -0
- package/starters/examples/src/examples/fx/shots.ts +71 -0
- package/starters/examples/src/examples/fx/spells.look.ts +87 -0
- package/starters/examples/src/examples/fx/spells.ts +122 -0
- package/starters/examples/src/examples/fx/stage.ts +31 -0
- package/starters/examples/src/examples/gui/babylonScreen.ts +167 -0
- package/starters/examples/src/examples/gui/babylonWorld.ts +191 -0
- package/starters/examples/src/examples/gui/htmlPart.ts +95 -0
- package/starters/examples/src/examples/gui/index.ts +143 -0
- package/starters/examples/src/examples/gui/keys.ts +91 -0
- package/starters/examples/src/examples/gui/layout.ts +42 -0
- package/starters/examples/src/examples/gui/looks.ts +270 -0
- package/starters/examples/src/examples/gui/minimap.ts +127 -0
- package/starters/examples/src/examples/gui/ownInterface.ts +129 -0
- package/starters/examples/src/examples/gui/plates.ts +87 -0
- package/starters/examples/src/examples/gui/radial.ts +104 -0
- package/starters/examples/src/examples/gui/solidPart.ts +107 -0
- package/starters/examples/src/examples/looks.ts +45 -0
- package/starters/examples/src/examples/mechanics/battle.ts +51 -0
- package/starters/examples/src/examples/mechanics/damage.ts +80 -0
- package/starters/examples/src/examples/mechanics/flying.ts +93 -0
- package/starters/examples/src/examples/mechanics/formations.ts +58 -0
- package/starters/examples/src/examples/mechanics/highGround.ts +82 -0
- package/starters/examples/src/examples/mechanics/index.ts +44 -0
- package/starters/examples/src/examples/mechanics/looks.ts +289 -0
- package/starters/examples/src/examples/mechanics/maze.ts +119 -0
- package/starters/examples/src/examples/mechanics/orders.ts +68 -0
- package/starters/examples/src/examples/mechanics/selection.ts +30 -0
- package/starters/examples/src/examples/moba/index.ts +377 -0
- package/starters/examples/src/examples/moba/looks.ts +349 -0
- package/starters/examples/src/examples/moba/maps.ts +69 -0
- package/starters/examples/src/examples/moba/spells.ts +34 -0
- package/starters/examples/src/examples/moba/units.ts +196 -0
- package/starters/examples/src/examples/nature/camera.ts +15 -0
- package/starters/examples/src/examples/nature/grass.ts +64 -0
- package/starters/examples/src/examples/nature/index.ts +327 -0
- package/starters/examples/src/examples/nature/land.ts +173 -0
- package/starters/examples/src/examples/nature/looks.ts +577 -0
- package/starters/examples/src/examples/nature/maps.ts +370 -0
- package/starters/examples/src/examples/nature/skies.ts +324 -0
- package/starters/examples/src/examples/nature/weather.ts +80 -0
- package/starters/examples/src/examples/performance/index.ts +263 -0
- package/starters/examples/src/examples/performance/looks.ts +372 -0
- package/starters/examples/src/examples/performance/meter.ts +158 -0
- package/starters/examples/src/examples/post/grades.ts +86 -0
- package/starters/examples/src/examples/post/index.ts +202 -0
- package/starters/examples/src/examples/post/looks.ts +884 -0
- package/starters/examples/src/examples/post/passes.ts +213 -0
- package/starters/examples/src/examples/rts/ai.ts +49 -0
- package/starters/examples/src/examples/rts/airlift.ts +43 -0
- package/starters/examples/src/examples/rts/base.ts +129 -0
- package/starters/examples/src/examples/rts/build.ts +65 -0
- package/starters/examples/src/examples/rts/camps.ts +36 -0
- package/starters/examples/src/examples/rts/carry.ts +103 -0
- package/starters/examples/src/examples/rts/creeps.ts +61 -0
- package/starters/examples/src/examples/rts/data.ts +406 -0
- package/starters/examples/src/examples/rts/ferry.ts +41 -0
- package/starters/examples/src/examples/rts/garrison.ts +50 -0
- package/starters/examples/src/examples/rts/harvest.ts +29 -0
- package/starters/examples/src/examples/rts/hero.ts +67 -0
- package/starters/examples/src/examples/rts/index.ts +77 -0
- package/starters/examples/src/examples/rts/looks.ts +721 -0
- package/starters/examples/src/examples/rts/shop.ts +46 -0
- package/starters/examples/src/examples/rts/tech.ts +39 -0
- package/starters/examples/src/examples/rts/train.ts +48 -0
- package/starters/examples/src/examples/rts/waterside.ts +185 -0
- package/starters/examples/src/examples/showcase/index.ts +714 -0
- package/starters/examples/src/examples/showcase/looks.ts +238 -0
- package/starters/examples/src/game.ts +17 -0
- package/starters/examples/src/main.ts +82 -0
- package/starters/examples/src/rules.ts +31 -0
- package/starters/examples/src/shell/look.ts +79 -0
- package/starters/examples/src/shell/registry.ts +7 -0
- package/starters/examples/src/shell/scenes.ts +178 -0
- package/starters/examples/src/shell/shell.ts +290 -0
- package/starters/examples/src/shell/style.ts +202 -0
- package/starters/examples/src/view.ts +50 -0
- package/starters/melee/README.md +12 -3
- package/starters/melee/layers/tests/tests/heroes.test.ts +402 -0
- package/starters/melee/layers/tests/tests/rules.test.ts +10 -8
- package/starters/melee/src/game.ts +32 -2
- package/starters/melee/src/maps/bay.names.ts +2 -2
- package/starters/melee/src/maps/bay.vgmap.json +198 -1
- package/starters/melee/src/maps.ts +11 -9
- package/starters/melee/src/melee/abilities.ts +1 -194
- package/starters/melee/src/melee/blight.ts +7 -7
- package/starters/melee/src/melee/cellLayers.json +3 -3
- package/starters/melee/src/melee/data.json +1 -18
- package/starters/melee/src/melee/game.ts +6 -4
- package/starters/melee/src/melee/heroes.ts +1025 -0
- package/starters/melee/src/melee/index.ts +64 -61
- package/starters/melee/src/melee/items.ts +15 -362
- package/starters/melee/src/melee/languages/he.json +14 -2
- package/starters/melee/src/melee/pings.ts +5 -6
- package/starters/melee/src/melee/saves.ts +1 -1
- package/starters/melee/src/melee/undead.ts +15 -230
- package/starters/melee/src/melee/units.ts +37 -171
- package/starters/melee/src/melee/view/attacks.ts +19 -0
- package/starters/melee/src/melee/view/heroSpellLooks.ts +39 -8
- package/starters/melee/src/melee/view/index.ts +56 -52
- package/starters/melee/src/melee/view/looks.ts +86 -63
- package/starters/melee/src/melee/view/undeadLooks.ts +70 -31
- package/starters/melee/src/melee/view/walls.ts +1 -1
- package/starters.mjs +6 -0
- package/v12.mjs +14 -15
- package/starters/campaign/src/rules.ts +0 -20
- package/starters/empty/src/maps.ts +0 -44
- package/starters/empty/src/tiles.ts +0 -12
- package/starters/melee/src/melee/heroSpells.ts +0 -38
- package/starters/melee/src/rules.ts +0 -24
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the desktop app adds to the game, in a file of its own: the rest of `src/` is the game's, and hands its view and
|
|
3
|
+
* its app to this file where a project is made with `--desktop` (`desktopView` where the view is given to the game,
|
|
4
|
+
* `desktop()` in `main.ts`). It asks the shell only through `shell.ts`, so the same page runs in a browser's tab while it
|
|
5
|
+
* is developed (`npm run dev`), where each of these is nothing
|
|
6
|
+
*/
|
|
7
|
+
import type { GameClient, GameView, SavedGame, SettingsRecord, SettingsStore } from '@v12-engine/client'
|
|
8
|
+
import type { AppShell, StartAppOptions, StartedApp } from '@v12-engine/client/app'
|
|
9
|
+
import {
|
|
10
|
+
autosave,
|
|
11
|
+
autosaveOnClose,
|
|
12
|
+
canQuit,
|
|
13
|
+
checkForUpdate,
|
|
14
|
+
createErrorLog,
|
|
15
|
+
type ErrorLog,
|
|
16
|
+
fileStore,
|
|
17
|
+
gameFullscreen,
|
|
18
|
+
inShell,
|
|
19
|
+
logFiles,
|
|
20
|
+
logPageErrors,
|
|
21
|
+
openFolder,
|
|
22
|
+
quit,
|
|
23
|
+
setWindowed,
|
|
24
|
+
watchWindowed
|
|
25
|
+
} from './shell'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The pause menu's lines of the app's own: in the shell, the folders of the saves and of the error log, and Quit; a page
|
|
29
|
+
* in a browser can close nothing and open no folder, so it has none of them
|
|
30
|
+
*/
|
|
31
|
+
export function menuEntries(): { id: string; label: string; run: () => void }[] {
|
|
32
|
+
return [
|
|
33
|
+
...(inShell()
|
|
34
|
+
? [
|
|
35
|
+
{ id: 'savesFolder', label: 'Open the saves folder', run: () => void openFolder('saves') },
|
|
36
|
+
{ id: 'logFolder', label: 'Open the error log folder', run: () => void openFolder('logs') }
|
|
37
|
+
]
|
|
38
|
+
: []),
|
|
39
|
+
...(canQuit() ? [{ id: 'quit', label: 'Quit', run: () => void quit() }] : [])
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The game's view with what a desktop game has that a page does not, said with what every game has: a setting of its
|
|
45
|
+
* own (Windowed, kept with the client's), lines of its own on the pause menu, and the HUD's own full
|
|
46
|
+
* screen button left out (the window's is the one: a second would disagree). The rest of the view is the game's, as
|
|
47
|
+
* it was
|
|
48
|
+
*/
|
|
49
|
+
export function desktopView<V extends GameView<any, any, any>>(view: V): V {
|
|
50
|
+
const hud = view.hud ?? {}
|
|
51
|
+
const entries = hud.menu?.entries
|
|
52
|
+
return {
|
|
53
|
+
...view,
|
|
54
|
+
hud: {
|
|
55
|
+
...hud,
|
|
56
|
+
hide: [...(hud.hide ?? []), 'fullscreen'],
|
|
57
|
+
menu: {
|
|
58
|
+
...hud.menu,
|
|
59
|
+
entries:
|
|
60
|
+
typeof entries === 'function'
|
|
61
|
+
? (client: Parameters<typeof entries>[0]) => [...entries(client), ...menuEntries()]
|
|
62
|
+
: [...(entries ?? []), ...menuEntries()]
|
|
63
|
+
},
|
|
64
|
+
settings: {
|
|
65
|
+
...hud.settings,
|
|
66
|
+
add: [
|
|
67
|
+
...(hud.settings?.add ?? []),
|
|
68
|
+
{
|
|
69
|
+
id: 'windowed',
|
|
70
|
+
label: 'Windowed',
|
|
71
|
+
hint: 'The game in a window with its frame, and not over the whole screen',
|
|
72
|
+
section: 'view',
|
|
73
|
+
kind: 'toggle',
|
|
74
|
+
// an app starts as a game does, over the whole screen; a page in a tab as a page
|
|
75
|
+
default: !inShell(),
|
|
76
|
+
apply: (_client: unknown, on: boolean) => void setWindowed(on)
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
// the app's window is the game, full screen or not (the shell's full screen is not the page's, which the client's
|
|
80
|
+
// "in full screen" looks at): the pointer stays in it, as in any game of the system's own
|
|
81
|
+
change: { ...hud.settings?.change, keepPointer: { default: inShell() ? 'always' : 'fullscreen' } }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What `desktop()` gives `main.ts`: the options `startApp` is given, what is done once the app is started, and what the start menu's Exit asks (`exitButton`) */
|
|
88
|
+
export interface Desktop extends AppShell {
|
|
89
|
+
options: Pick<StartAppOptions, 'stores' | 'onGame' | 'fullscreenButton' | 'fullscreenHost' | 'keepPointer'>
|
|
90
|
+
started(app: StartedApp): void
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The app in its shell. What the player saves and sets is files in the app's own folder of the system (`fileStore`),
|
|
95
|
+
* there the next time it is opened; what goes wrong is a line of `errors.log`; closing the window saves the game
|
|
96
|
+
* being played first; the window's full screen is the Windowed setting's, both ways; the start menu has an Exit; and a
|
|
97
|
+
* new version, where the shell finds one, is offered in a toast. In a browser: nothing, the browser keeps what it keeps
|
|
98
|
+
*/
|
|
99
|
+
export function desktop(): Desktop {
|
|
100
|
+
if (!inShell()) return { options: {}, canQuit, quit, started: () => {} }
|
|
101
|
+
const errors = createErrorLog(logFiles)
|
|
102
|
+
logPageErrors(errors)
|
|
103
|
+
const settings: SettingsStore = fileStore<SettingsRecord>('settings')
|
|
104
|
+
/** The game played now: the one the window's closing saves */
|
|
105
|
+
let playing: GameClient | undefined
|
|
106
|
+
void autosaveOnClose(() => playing, { log: (error) => void errors.write('autosave', error) }).catch((error) => errors.write('window', error))
|
|
107
|
+
// the window as the player left it the last time: over the screen, or a window (whose size and place the shell keeps)
|
|
108
|
+
void startWindow(settings, errors)
|
|
109
|
+
return {
|
|
110
|
+
canQuit,
|
|
111
|
+
quit,
|
|
112
|
+
options: {
|
|
113
|
+
// the window's full screen is the shell's, which the Windowed setting has: a button for the page's would disagree; and the
|
|
114
|
+
// pointer stays in the window on the app's own screens too, as in its game
|
|
115
|
+
fullscreenButton: false,
|
|
116
|
+
// and what a game of its own asks (`app.fullscreen()`, a corner button of its HUD) is that one too
|
|
117
|
+
fullscreenHost: gameFullscreen,
|
|
118
|
+
keepPointer: 'always',
|
|
119
|
+
stores: { saves: fileStore<SavedGame>('saves'), settings },
|
|
120
|
+
onGame(client) {
|
|
121
|
+
playing = client
|
|
122
|
+
let unwatch: (() => void) | undefined
|
|
123
|
+
let ended = false
|
|
124
|
+
void watchWindowed(client).then(
|
|
125
|
+
(off) => (ended ? off() : (unwatch = off)),
|
|
126
|
+
(error) => errors.write('window', error)
|
|
127
|
+
)
|
|
128
|
+
return () => {
|
|
129
|
+
ended = true
|
|
130
|
+
unwatch?.()
|
|
131
|
+
if (playing === client) playing = undefined
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
started(app) {
|
|
136
|
+
void offerUpdate(app, () => playing, errors)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The window as the setting says as the app starts: over the whole screen where it was never set */
|
|
142
|
+
async function startWindow(settings: SettingsStore, errors: ErrorLog): Promise<void> {
|
|
143
|
+
try {
|
|
144
|
+
await setWindowed((await settings.read('settings'))?.values.windowed === true)
|
|
145
|
+
} catch (error) {
|
|
146
|
+
await errors.write('window', error)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A new version offered, where the shell finds one: a toast says which, and its button saves the game being played,
|
|
152
|
+
* installs it and starts the app again. Only in a release built to have one (`VITE_DESKTOP_UPDATES=on`): an app built
|
|
153
|
+
* here has no address to ask (`UPDATES` in `electron/config.mjs` is a placeholder until the docs' Updates are done). What fails (no network, a signature that does not hold) is
|
|
154
|
+
* a line of the error log, not the player's business
|
|
155
|
+
*/
|
|
156
|
+
export async function offerUpdate(app: Pick<StartedApp, 'overlays'>, playing: () => GameClient | undefined, errors: ErrorLog): Promise<void> {
|
|
157
|
+
if (import.meta.env.VITE_DESKTOP_UPDATES !== 'on') return
|
|
158
|
+
try {
|
|
159
|
+
const update = await checkForUpdate()
|
|
160
|
+
if (!update) return
|
|
161
|
+
app.overlays.toast(`Version ${update.version} is ready`, {
|
|
162
|
+
seconds: 30,
|
|
163
|
+
action: {
|
|
164
|
+
label: 'Install and restart',
|
|
165
|
+
run: () =>
|
|
166
|
+
void autosave(playing())
|
|
167
|
+
.catch((error) => errors.write('autosave', error))
|
|
168
|
+
.then(() => update.install())
|
|
169
|
+
.catch((error) => errors.write('update', error))
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
} catch (error) {
|
|
173
|
+
await errors.write('update', error)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the page asks of the desktop shell, each with what it does in a browser, where the same page runs while it is
|
|
3
|
+
* developed (`npm run dev`): the page never names Electron outside this file, and asks the main process
|
|
4
|
+
* (`electron/app.mjs`) only through what `electron/preload.cjs` puts on the window
|
|
5
|
+
*/
|
|
6
|
+
import type { GameClient, Store } from '@v12-engine/client'
|
|
7
|
+
|
|
8
|
+
/** What the preload script puts on the window */
|
|
9
|
+
export interface ShellBridge {
|
|
10
|
+
fullscreen(on?: boolean): Promise<boolean>
|
|
11
|
+
close(): Promise<void>
|
|
12
|
+
destroy(): Promise<void>
|
|
13
|
+
onResized(listener: () => void): () => void
|
|
14
|
+
onCloseRequested(listener: () => void): () => void
|
|
15
|
+
openFolder(folder: string): Promise<void>
|
|
16
|
+
records: {
|
|
17
|
+
list(folder: string): Promise<string[]>
|
|
18
|
+
read(folder: string, id: string): Promise<string | undefined>
|
|
19
|
+
write(folder: string, id: string, text: string): Promise<void>
|
|
20
|
+
remove(folder: string, id: string): Promise<void>
|
|
21
|
+
}
|
|
22
|
+
log: {
|
|
23
|
+
append(file: string, text: string): Promise<void>
|
|
24
|
+
size(file: string): Promise<number>
|
|
25
|
+
rename(from: string, to: string): Promise<void>
|
|
26
|
+
}
|
|
27
|
+
updates: {
|
|
28
|
+
check(): Promise<{ version: string; notes?: string } | undefined>
|
|
29
|
+
install(): Promise<void>
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The bridge, in the shell */
|
|
34
|
+
const bridge = (): ShellBridge => (window as unknown as { v12Shell: ShellBridge }).v12Shell
|
|
35
|
+
|
|
36
|
+
/** Whether the page is in the shell (the preload script says so on the window), not in a browser's tab */
|
|
37
|
+
export const inShell = (): boolean => 'v12Shell' in window
|
|
38
|
+
|
|
39
|
+
/** How long the window takes over going over the screen or back: what it says meanwhile is not what the player asked */
|
|
40
|
+
const SETTLES_IN = 2000
|
|
41
|
+
/** Until when the window is being changed by `setWindowed`, and `watchWindowed` listens to none of it */
|
|
42
|
+
let settlingUntil = 0
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The game over the whole screen with no frame, or a window with one, by the shell (`electron/app.mjs`: on macOS no Dock
|
|
46
|
+
* or menu bar at the edges, and Esc is the game's), or told which it is when `on` is left out. This is `startApp`'s
|
|
47
|
+
* `fullscreenHost`, so that nothing of the page asks for the page's own full screen. False in a browser
|
|
48
|
+
*/
|
|
49
|
+
export async function gameFullscreen(on?: boolean): Promise<boolean> {
|
|
50
|
+
if (!inShell()) return false
|
|
51
|
+
return bridge().fullscreen(on)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A window with its frame, or the game over the whole screen with none (as the shell starts it): the shell's own
|
|
56
|
+
* window, where a browser has only the page's
|
|
57
|
+
*/
|
|
58
|
+
export async function setWindowed(on: boolean): Promise<void> {
|
|
59
|
+
if (inShell()) {
|
|
60
|
+
settlingUntil = Date.now() + SETTLES_IN
|
|
61
|
+
await gameFullscreen(!on)
|
|
62
|
+
settlingUntil = Date.now() + SETTLES_IN
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
// a browser gives it to a click only: as a setting is applied when the game starts, it may say no
|
|
66
|
+
if (!on) await document.documentElement.requestFullscreen().catch(() => undefined)
|
|
67
|
+
else if (document.fullscreenElement) await document.exitFullscreen()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether the game can be quit from its menu: in the shell only. A page cannot close its own tab (`window.close` does
|
|
72
|
+
* nothing for a tab the page did not open), so a browser's menu has no Quit
|
|
73
|
+
*/
|
|
74
|
+
export const canQuit = (): boolean => inShell()
|
|
75
|
+
|
|
76
|
+
/** Out of the game: the window closed (which saves the game first, `autosaveOnClose`), and the app with its last one. Nothing in a browser */
|
|
77
|
+
export async function quit(): Promise<void> {
|
|
78
|
+
if (inShell()) return bridge().close()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What `watchWindowed` needs of a client: the settings, in which the window's state is kept */
|
|
82
|
+
type WithSettings = Pick<GameClient, 'settings'>
|
|
83
|
+
|
|
84
|
+
/** What `watchWindowed` needs of a window */
|
|
85
|
+
interface Resizable {
|
|
86
|
+
onResized(handler: () => void): Promise<() => void>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Keeps the "Windowed" setting, the one control of it (the HUD's own button is left out, `GameView.hud.hide`), true to
|
|
91
|
+
* the window: a window that was put in or out of full screen by anything else (`StartedApp.fullscreen`) sets it, so that
|
|
92
|
+
* the toggle shows what the window is. What the window says while the setting itself moves it is passed over: it is not
|
|
93
|
+
* there yet, and would put the setting back
|
|
94
|
+
*/
|
|
95
|
+
export async function watchWindowed(
|
|
96
|
+
client: WithSettings,
|
|
97
|
+
window: Resizable = { onResized: async (handler) => bridge().onResized(handler) },
|
|
98
|
+
isOn: () => Promise<boolean> = gameFullscreen
|
|
99
|
+
): Promise<() => void> {
|
|
100
|
+
return window.onResized(async () => {
|
|
101
|
+
if (Date.now() < settlingUntil) return
|
|
102
|
+
const windowed = !(await isOn())
|
|
103
|
+
if (Date.now() < settlingUntil) return
|
|
104
|
+
if (client.settings.get('windowed') !== windowed) client.settings.set('windowed', windowed)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The game's name for the save made as the window closes: the one before it is replaced, so there is one */
|
|
109
|
+
export const AUTOSAVE = 'Autosave'
|
|
110
|
+
|
|
111
|
+
/** What an autosave needs of a client: its saves (not there when the game is launched with none) */
|
|
112
|
+
type WithSaves = { readonly saves?: Pick<NonNullable<GameClient['saves']>, 'canSave' | 'list' | 'refresh' | 'save' | 'remove'> }
|
|
113
|
+
|
|
114
|
+
/** The game saved as "Autosave", the one made before taken away. Nothing when there is no game to save; whether it was saved */
|
|
115
|
+
export async function autosave(client: WithSaves | undefined): Promise<boolean> {
|
|
116
|
+
const saves = client?.saves
|
|
117
|
+
if (!saves || !saves.canSave()) return false
|
|
118
|
+
await saves.refresh()
|
|
119
|
+
const before = saves.list().filter((saved) => saved.name === AUTOSAVE)
|
|
120
|
+
const saved = await saves.save(AUTOSAVE)
|
|
121
|
+
if (!saved) return false
|
|
122
|
+
// only once the new one is kept are the old ones given up
|
|
123
|
+
for (const old of before) await saves.remove(old.id)
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** What `autosaveOnClose` needs of a window */
|
|
128
|
+
interface Closable {
|
|
129
|
+
onCloseRequested(handler: () => void | Promise<void>): Promise<() => void>
|
|
130
|
+
destroy(): Promise<void>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The window's closing (its X, Cmd+Q, the menu's Quit) saves the game first: the shell holds the close until the page
|
|
135
|
+
* destroys the window, which it does once the save is done, but no more than `limit` ms (a save that hangs does not
|
|
136
|
+
* keep the app open; the shell's own limit is longer), and a save that fails is logged, not the end of the player's wish
|
|
137
|
+
* to leave
|
|
138
|
+
*/
|
|
139
|
+
export async function autosaveOnClose(
|
|
140
|
+
getClient: () => WithSaves | undefined,
|
|
141
|
+
options: { window?: Closable; limit?: number; log?: (error: unknown) => void } = {}
|
|
142
|
+
): Promise<() => void> {
|
|
143
|
+
const {
|
|
144
|
+
window = { onCloseRequested: async (handler) => bridge().onCloseRequested(handler), destroy: () => bridge().destroy() },
|
|
145
|
+
limit = 4000,
|
|
146
|
+
log
|
|
147
|
+
} = options
|
|
148
|
+
return window.onCloseRequested(async () => {
|
|
149
|
+
try {
|
|
150
|
+
await Promise.race([autosave(getClient()), new Promise((resolve) => setTimeout(resolve, limit))])
|
|
151
|
+
} catch (error) {
|
|
152
|
+
log?.(error)
|
|
153
|
+
}
|
|
154
|
+
await window.destroy()
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The folders the app keeps things in: the saves under its own folder of the system, the logs in the system's place for them */
|
|
159
|
+
export const FOLDERS = { saves: 'saves', logs: 'logs' } as const
|
|
160
|
+
|
|
161
|
+
/** Opens one of the app's folders in the system's file manager (made first, so that there is something to open) */
|
|
162
|
+
export async function openFolder(folder: keyof typeof FOLDERS): Promise<void> {
|
|
163
|
+
if (inShell()) await bridge().openFolder(folder)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What the error log asks of the files: append to one, its size, and a move (the shell's are `logFiles`) */
|
|
167
|
+
export interface LogFiles {
|
|
168
|
+
append(file: string, text: string): Promise<void>
|
|
169
|
+
size(file: string): Promise<number>
|
|
170
|
+
rename(from: string, to: string): Promise<void>
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The files of the app's log folder, the system's own (`~/Library/Logs/<name>`, `%APPDATA%\<identifier>\logs`): `errors.log` and `errors.old.log` only */
|
|
174
|
+
export const logFiles: LogFiles = {
|
|
175
|
+
append: (file, text) => bridge().log.append(file, text),
|
|
176
|
+
size: (file) => bridge().log.size(file),
|
|
177
|
+
rename: (from, to) => bridge().log.rename(from, to)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** What an error log is: a line for each, written to `errors.log`; when that is over `maxBytes` it becomes `errors.old.log` (one generation kept) */
|
|
181
|
+
export interface ErrorLog {
|
|
182
|
+
write(where: string, error: unknown): Promise<void>
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The text of whatever was thrown: an Error with its stack, a string, anything else as JSON */
|
|
186
|
+
export function describeError(error: unknown): string {
|
|
187
|
+
if (error instanceof Error) return error.stack ?? `${error.name}: ${error.message}`
|
|
188
|
+
if (typeof error === 'string') return error
|
|
189
|
+
try {
|
|
190
|
+
return JSON.stringify(error) ?? String(error)
|
|
191
|
+
} catch {
|
|
192
|
+
return String(error)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The error log: what went wrong in the page, as lines of a file the player can send. A failing log never throws (an
|
|
198
|
+
* error while logging one is lost, not the game), and writes are in order
|
|
199
|
+
*/
|
|
200
|
+
export function createErrorLog(files: LogFiles, options: { maxBytes?: number; now?: () => Date } = {}): ErrorLog {
|
|
201
|
+
const { maxBytes = 512 * 1024, now = () => new Date() } = options
|
|
202
|
+
let chain: Promise<void> = Promise.resolve()
|
|
203
|
+
return {
|
|
204
|
+
write(where, error) {
|
|
205
|
+
chain = chain.then(async () => {
|
|
206
|
+
try {
|
|
207
|
+
if ((await files.size('errors.log')) > maxBytes) await files.rename('errors.log', 'errors.old.log')
|
|
208
|
+
const text = describeError(error).replace(/\r?\n/g, '\n ')
|
|
209
|
+
await files.append('errors.log', `${now().toISOString()} [${where}] ${text}\n`)
|
|
210
|
+
} catch {
|
|
211
|
+
// nothing more can be done about it
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
return chain
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The page's uncaught errors and rejected promises go to the log. Gives back what takes them off again */
|
|
220
|
+
export function logPageErrors(log: ErrorLog, target: Pick<Window, 'addEventListener' | 'removeEventListener'> = window): () => void {
|
|
221
|
+
const onError = (event: Event) => void log.write('error', (event as ErrorEvent).error ?? (event as ErrorEvent).message)
|
|
222
|
+
const onRejection = (event: Event) => void log.write('unhandledrejection', (event as PromiseRejectionEvent).reason)
|
|
223
|
+
target.addEventListener('error', onError)
|
|
224
|
+
target.addEventListener('unhandledrejection', onRejection)
|
|
225
|
+
return () => {
|
|
226
|
+
target.removeEventListener('error', onError)
|
|
227
|
+
target.removeEventListener('unhandledrejection', onRejection)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A store of records as files: one JSON file for each, `<folder>/<id>.json`, in the app's own data folder of the system
|
|
233
|
+
* (under its identifier, `electron/config.mjs`), written by the main process (`electron/files.mjs`, which checks every
|
|
234
|
+
* name, and writes a record whole or not at all). What a player saves and sets is there the next time the app is
|
|
235
|
+
* opened, and can be found, copied or backed up with the rest of the app's folder. A file that does not read as a
|
|
236
|
+
* record (cut short, edited by hand) is left out. A browser has none of these and keeps them in its own storage
|
|
237
|
+
*/
|
|
238
|
+
export function fileStore<R extends { id: string }>(folder: string): Store<R> {
|
|
239
|
+
const parsed = (text: string | undefined): R | undefined => {
|
|
240
|
+
if (text === undefined) return undefined
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(text) as R
|
|
243
|
+
} catch {
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
async list() {
|
|
249
|
+
const texts = await bridge().records.list(folder)
|
|
250
|
+
return texts.flatMap((text) => parsed(text) ?? [])
|
|
251
|
+
},
|
|
252
|
+
read: async (id) => parsed(await bridge().records.read(folder, id)),
|
|
253
|
+
write: (record) => bridge().records.write(folder, record.id, JSON.stringify(record)),
|
|
254
|
+
remove: (id) => bridge().records.remove(folder, id)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A new version of the app, found at the address of `electron/config.mjs` (`UPDATES`) and downloaded */
|
|
259
|
+
export interface AppUpdate {
|
|
260
|
+
/** The version it is, as its feed names it */
|
|
261
|
+
version: string
|
|
262
|
+
/** Its notes, as its feed says: none where it says none */
|
|
263
|
+
notes?: string
|
|
264
|
+
/** The app quit, the new version put in its place and started (the game is the caller's to save first) */
|
|
265
|
+
install(): Promise<void>
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A newer version of the app, where there is one, by Electron's `autoUpdater` (`electron/updates.mjs`): downloaded before
|
|
270
|
+
* it is offered. None in a browser, none in an app run from the project or whose address is the placeholder, none when the
|
|
271
|
+
* app is the newest. A feed that cannot be had, or an update that does not hold (Squirrel.Mac checks the new app's
|
|
272
|
+
* signature against this one's), throws: the caller logs it
|
|
273
|
+
*/
|
|
274
|
+
export async function checkForUpdate(): Promise<AppUpdate | undefined> {
|
|
275
|
+
if (!inShell()) return undefined
|
|
276
|
+
const found = await bridge().updates.check()
|
|
277
|
+
if (!found) return undefined
|
|
278
|
+
return { version: found.version, ...(found.notes && { notes: found.notes }), install: () => bridge().updates.install() }
|
|
279
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// @if tests
|
|
2
|
+
/** What the main process does with the page's words: the disk's, the scheme's and the updater's parts, which need no Electron to run */
|
|
3
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
7
|
+
// @ts-expect-error plain JavaScript for the main process, with no types of its own
|
|
8
|
+
import { fileNameOf, logFiles, recordFiles } from '../electron/files.mjs'
|
|
9
|
+
// @ts-expect-error plain JavaScript for the main process, with no types of its own
|
|
10
|
+
import { answer, contentPolicy } from '../electron/serve.mjs'
|
|
11
|
+
// @ts-expect-error plain JavaScript for the main process, with no types of its own
|
|
12
|
+
import { feedOf, updatesOf } from '../electron/updates.mjs'
|
|
13
|
+
// @ts-expect-error plain JavaScript for the main process, with no types of its own
|
|
14
|
+
import { UPDATES } from '../electron/config.mjs'
|
|
15
|
+
import { EventEmitter } from 'node:events'
|
|
16
|
+
|
|
17
|
+
/** A folder of the system's for one test, inside one for the file, which goes when the file's tests are done */
|
|
18
|
+
const under = mkdtempSync(join(tmpdir(), 'v12-electron-test-'))
|
|
19
|
+
const folder = () => mkdtempSync(join(under, 'case-'))
|
|
20
|
+
afterAll(() => rmSync(under, { recursive: true, force: true }))
|
|
21
|
+
|
|
22
|
+
describe('records', () => {
|
|
23
|
+
it('keeps a record whole and leaves no partial file', async () => {
|
|
24
|
+
const base = folder()
|
|
25
|
+
const records = recordFiles(base)
|
|
26
|
+
await records.write('saves', 'one', '{"id":"one"}')
|
|
27
|
+
expect(await records.read('saves', 'one')).toBe('{"id":"one"}')
|
|
28
|
+
expect(await records.list('saves')).toEqual(['{"id":"one"}'])
|
|
29
|
+
expect(readdirSync(join(base, 'saves'))).toEqual(['one.json'])
|
|
30
|
+
await records.remove('saves', 'one')
|
|
31
|
+
expect(await records.read('saves', 'one')).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('says nothing of a folder not made yet', async () => {
|
|
35
|
+
expect(await recordFiles(folder()).list('saves')).toEqual([])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("keeps a record of any id inside its folder, and refuses a folder that is not the app's", async () => {
|
|
39
|
+
const base = folder()
|
|
40
|
+
const records = recordFiles(base)
|
|
41
|
+
// a checkpoint's id, and ids that would leave the folder were they a path
|
|
42
|
+
const ids = ['checkpoint:desktop:campaign-mission:{"mission":"homecoming"}', '../../x', 'a/b', '..', '.hidden', 'x'.repeat(400)]
|
|
43
|
+
for (const id of ids) await records.write('saves', id, JSON.stringify({ id }))
|
|
44
|
+
for (const id of ids) expect(await records.read('saves', id)).toBe(JSON.stringify({ id }))
|
|
45
|
+
const names = readdirSync(join(base, 'saves'))
|
|
46
|
+
expect(names).toHaveLength(ids.length)
|
|
47
|
+
for (const name of names) expect(name).toMatch(/^~[\w-]+\.json$/)
|
|
48
|
+
expect(readdirSync(base)).toEqual(['saves'])
|
|
49
|
+
expect(fileNameOf('a-save.1')).toBe('a-save.1')
|
|
50
|
+
await expect(records.write('saves', '', '{}')).rejects.toThrow('not the id')
|
|
51
|
+
await expect(records.write('../..', 'x', '{}')).rejects.toThrow('not a folder')
|
|
52
|
+
await expect(records.list('anything')).rejects.toThrow('not a folder')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('the log', () => {
|
|
57
|
+
it('appends to its two files and to no other', async () => {
|
|
58
|
+
const base = folder()
|
|
59
|
+
const log = logFiles(base)
|
|
60
|
+
expect(await log.size('errors.log')).toBe(0)
|
|
61
|
+
await log.append('errors.log', 'a line\n')
|
|
62
|
+
expect(await log.size('errors.log')).toBe(7)
|
|
63
|
+
await log.rename('errors.log', 'errors.old.log')
|
|
64
|
+
expect(readFileSync(join(base, 'errors.old.log'), 'utf8')).toBe('a line\n')
|
|
65
|
+
await expect(log.append('../x', 'y')).rejects.toThrow('not a file of the log')
|
|
66
|
+
await expect(log.rename('errors.log', '../x')).rejects.toThrow('not a file of the log')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe("the page's files", () => {
|
|
71
|
+
const root = () => {
|
|
72
|
+
const base = folder()
|
|
73
|
+
mkdirSync(join(base, 'assets'))
|
|
74
|
+
writeFileSync(join(base, 'index.html'), '<html>')
|
|
75
|
+
writeFileSync(join(base, 'assets/a.wasm'), 'abcdefghij')
|
|
76
|
+
return base
|
|
77
|
+
}
|
|
78
|
+
const get = (path: string, headers = {}) => new Request(`http://game${path}`, { headers })
|
|
79
|
+
|
|
80
|
+
it('answers the page with its policy, and WebAssembly as what it is', async () => {
|
|
81
|
+
const base = root()
|
|
82
|
+
const index = await answer(base, get('/'), 'the policy')
|
|
83
|
+
expect(await index.text()).toBe('<html>')
|
|
84
|
+
expect(index.headers.get('content-security-policy')).toBe('the policy')
|
|
85
|
+
expect((await answer(base, get('/assets/a.wasm'), '')).headers.get('content-type')).toBe('application/wasm')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('gives a part of a file where a part is asked', async () => {
|
|
89
|
+
const part = await answer(root(), get('/assets/a.wasm', { range: 'bytes=2-4' }), '')
|
|
90
|
+
expect(part.status).toBe(206)
|
|
91
|
+
expect(await part.text()).toBe('cde')
|
|
92
|
+
expect(part.headers.get('content-range')).toBe('bytes 2-4/10')
|
|
93
|
+
const last = await answer(root(), get('/assets/a.wasm', { range: 'bytes=-3' }), '')
|
|
94
|
+
expect(await last.text()).toBe('hij')
|
|
95
|
+
expect((await answer(root(), get('/assets/a.wasm', { range: 'bytes=50-' }), '')).status).toBe(416)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('answers nothing outside its folder, however it is written', async () => {
|
|
99
|
+
const base = root()
|
|
100
|
+
writeFileSync(join(base, '..', 'v12-outside.txt'), 'secret')
|
|
101
|
+
// (a URL's own dots are gone by the time it is read: `%2e%2e` and an encoded slash are what could still get out)
|
|
102
|
+
for (const path of ['/../v12-outside.txt', '/%2e%2e/v12-outside.txt', '/assets/..%2f..%2fv12-outside.txt', '/nothing.js'])
|
|
103
|
+
expect((await answer(base, get(path), '')).status).toBe(404)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('says a policy of its own origin, WebAssembly and nothing else, with the addresses it is given', () => {
|
|
107
|
+
const directives: Record<string, string[]> = Object.fromEntries(
|
|
108
|
+
(contentPolicy() as string).split(';').map((part) => {
|
|
109
|
+
const [name, ...sources] = part.trim().split(/\s+/)
|
|
110
|
+
return [name, sources]
|
|
111
|
+
})
|
|
112
|
+
)
|
|
113
|
+
expect(directives['default-src']).toEqual(["'self'"])
|
|
114
|
+
// the ground's worker and Babylon's textures and sounds made in the page
|
|
115
|
+
for (const name of ['worker-src', 'img-src', 'media-src']) expect(directives[name], name).toEqual(expect.arrayContaining(["'self'", 'blob:']))
|
|
116
|
+
// WebAssembly: the navigation, Havok's physics
|
|
117
|
+
expect(directives['script-src']).toEqual(["'self'", "'wasm-unsafe-eval'"])
|
|
118
|
+
// and no other site, but those it is given
|
|
119
|
+
for (const sources of Object.values(directives)) for (const source of sources) expect(source).not.toMatch(/^https?:/)
|
|
120
|
+
expect(contentPolicy(['http://127.0.0.1:1'])).toContain("connect-src 'self' blob: data: http://127.0.0.1:1")
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('updates', () => {
|
|
125
|
+
it('asks nothing while the address is the placeholder, and nothing on a system Squirrel does not update', () => {
|
|
126
|
+
expect(new URL(UPDATES).hostname.endsWith('.invalid')).toBe(true)
|
|
127
|
+
expect(feedOf(UPDATES, 'darwin', 'arm64')).toBeUndefined()
|
|
128
|
+
expect(feedOf('not an address', 'darwin', 'arm64')).toBeUndefined()
|
|
129
|
+
expect(feedOf('https://example.com/game', 'linux', 'x64')).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("reads the static files laid out by system and processor, as Electron's docs lay them", () => {
|
|
133
|
+
expect(feedOf('https://example.com/game/', 'darwin', 'arm64')).toEqual({
|
|
134
|
+
url: 'https://example.com/game/darwin/arm64/RELEASES.json',
|
|
135
|
+
serverType: 'json'
|
|
136
|
+
})
|
|
137
|
+
expect(feedOf('https://example.com/game', 'win32', 'x64')).toEqual({ url: 'https://example.com/game/win32/x64' })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
/** Electron's `autoUpdater`, pretended: what it is asked, and what it says when told to */
|
|
141
|
+
const pretendUpdater = (says: (updater: EventEmitter) => void) => {
|
|
142
|
+
const updater = Object.assign(new EventEmitter(), {
|
|
143
|
+
asked: [] as unknown[],
|
|
144
|
+
setFeedURL(feed: unknown) {
|
|
145
|
+
updater.asked.push(['feed', feed])
|
|
146
|
+
},
|
|
147
|
+
checkForUpdates() {
|
|
148
|
+
updater.asked.push(['check'])
|
|
149
|
+
setTimeout(() => says(updater))
|
|
150
|
+
},
|
|
151
|
+
quitAndInstall() {
|
|
152
|
+
updater.asked.push(['install'])
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
return updater
|
|
156
|
+
}
|
|
157
|
+
const feed = { url: 'https://example.com/game/darwin/arm64/RELEASES.json', serverType: 'json' }
|
|
158
|
+
|
|
159
|
+
it('gives a version once it is downloaded, nothing when there is none, and throws what fails', async () => {
|
|
160
|
+
const found = pretendUpdater((updater) => updater.emit('update-downloaded', {}, 'What is new', '1.2.0'))
|
|
161
|
+
const updates = updatesOf(found, feed, true)
|
|
162
|
+
expect(await updates.check()).toEqual({ version: '1.2.0', notes: 'What is new' })
|
|
163
|
+
updates.install()
|
|
164
|
+
expect(found.asked).toEqual([['feed', feed], ['check'], ['install']])
|
|
165
|
+
expect(found.listenerCount('error')).toBe(0)
|
|
166
|
+
expect(
|
|
167
|
+
await updatesOf(
|
|
168
|
+
pretendUpdater((updater) => updater.emit('update-not-available')),
|
|
169
|
+
feed,
|
|
170
|
+
true
|
|
171
|
+
).check()
|
|
172
|
+
).toBeUndefined()
|
|
173
|
+
await expect(
|
|
174
|
+
updatesOf(
|
|
175
|
+
pretendUpdater((updater) => updater.emit('error', new Error('no network'))),
|
|
176
|
+
feed,
|
|
177
|
+
true
|
|
178
|
+
).check()
|
|
179
|
+
).rejects.toThrow('no network')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('asks nothing of an app run from the project, or with no feed', async () => {
|
|
183
|
+
const updater = pretendUpdater(() => undefined)
|
|
184
|
+
expect(await updatesOf(updater, feed, false).check()).toBeUndefined()
|
|
185
|
+
expect(await updatesOf(updater, undefined, true).check()).toBeUndefined()
|
|
186
|
+
expect(updater.asked).toEqual([])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
// @end
|