@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.
Files changed (263) hide show
  1. package/README.md +4 -1
  2. package/base/_gitignore +5 -0
  3. package/base/package.json +3 -3
  4. package/base/src/main.ts +36 -10
  5. package/base/src/navigation.worker.ts +7 -0
  6. package/base/vite.config.ts +24 -2
  7. package/cli.mjs +21 -6
  8. package/index.mjs +27 -7
  9. package/layers/desktop/electron/app.mjs +237 -0
  10. package/layers/desktop/electron/config.mjs +16 -0
  11. package/layers/desktop/electron/dev.mjs +63 -0
  12. package/layers/desktop/electron/files.mjs +86 -0
  13. package/layers/desktop/electron/icons/icon.icns +0 -0
  14. package/layers/desktop/electron/icons/icon.png +0 -0
  15. package/layers/desktop/electron/main.mjs +12 -0
  16. package/layers/desktop/electron/package.mjs +75 -0
  17. package/layers/desktop/electron/preload.cjs +41 -0
  18. package/layers/desktop/electron/presentation.m +87 -0
  19. package/layers/desktop/electron/serve.mjs +76 -0
  20. package/layers/desktop/electron/tools.mjs +63 -0
  21. package/layers/desktop/electron/updates.mjs +71 -0
  22. package/layers/desktop/package.json +13 -0
  23. package/layers/desktop/src/desktop.ts +175 -0
  24. package/layers/desktop/src/shell.ts +279 -0
  25. package/layers/desktop/tests/main.test.ts +189 -0
  26. package/layers/desktop/tests/shell.test.ts +152 -0
  27. package/layers/rooms/server.ts +13 -10
  28. package/layers/server/server.ts +4 -5
  29. package/layers/source/vite.config.ts +21 -1
  30. package/package.json +1 -1
  31. package/starters/campaign/README.md +9 -2
  32. package/starters/campaign/layers/rooms/server.ts +3 -3
  33. package/starters/campaign/layers/server/server.ts +3 -5
  34. package/starters/campaign/layers/tests/tests/rules.test.ts +40 -21
  35. package/starters/campaign/src/campaign/game.ts +26 -5
  36. package/starters/campaign/src/campaign/index.ts +104 -68
  37. package/starters/campaign/src/campaign/kills.ts +1 -1
  38. package/starters/campaign/src/campaign/missions/homecoming.ts +7 -7
  39. package/starters/campaign/src/campaign/missions/siege.ts +6 -6
  40. package/starters/campaign/src/campaign/missions/warlord.ts +13 -14
  41. package/starters/campaign/src/campaign/scenes.ts +8 -7
  42. package/starters/campaign/src/campaign/units.ts +59 -20
  43. package/starters/campaign/src/campaign/view/attacks.ts +19 -0
  44. package/starters/campaign/src/campaign/view/fire.ts +2 -2
  45. package/starters/campaign/src/campaign/view/index.ts +54 -43
  46. package/starters/campaign/src/campaign/waves.ts +18 -17
  47. package/starters/campaign/src/game.ts +8 -1
  48. package/starters/campaign/src/main.ts +38 -19
  49. package/starters/campaign/src/maps/fort.names.ts +2 -2
  50. package/starters/campaign/src/maps/fort.vgmap.json +102 -2
  51. package/starters/campaign/src/maps/homestead.names.ts +2 -2
  52. package/starters/campaign/src/maps/homestead.vgmap.json +85 -1
  53. package/starters/campaign/src/maps/pass.names.ts +2 -2
  54. package/starters/campaign/src/maps/pass.vgmap.json +69 -1
  55. package/starters/campaign/src/waves-panel.ts +2 -1
  56. package/starters/empty/README.md +18 -10
  57. package/starters/empty/layers/tests/tests/rules.test.ts +16 -17
  58. package/starters/empty/src/game.ts +11 -75
  59. package/starters/empty/src/generate.worker.ts +5 -5
  60. package/starters/empty/src/rules.ts +83 -25
  61. package/starters/empty/src/view.ts +82 -0
  62. package/starters/examples/README.md +83 -0
  63. package/starters/examples/layers/tests/tests/examples.test.ts +30 -0
  64. package/starters/examples/package.json +5 -0
  65. package/starters/examples/src/examples/abilities/areas.ts +165 -0
  66. package/starters/examples/src/examples/abilities/areasLooks.ts +30 -0
  67. package/starters/examples/src/examples/abilities/buffs.ts +287 -0
  68. package/starters/examples/src/examples/abilities/buffsLooks.ts +46 -0
  69. package/starters/examples/src/examples/abilities/changing.ts +228 -0
  70. package/starters/examples/src/examples/abilities/changingLooks.ts +61 -0
  71. package/starters/examples/src/examples/abilities/control.ts +158 -0
  72. package/starters/examples/src/examples/abilities/controlLooks.ts +78 -0
  73. package/starters/examples/src/examples/abilities/damage.ts +156 -0
  74. package/starters/examples/src/examples/abilities/damageLooks.ts +37 -0
  75. package/starters/examples/src/examples/abilities/family.ts +29 -0
  76. package/starters/examples/src/examples/abilities/index.ts +32 -0
  77. package/starters/examples/src/examples/abilities/kit.ts +179 -0
  78. package/starters/examples/src/examples/abilities/lookKit.ts +71 -0
  79. package/starters/examples/src/examples/abilities/looks.ts +47 -0
  80. package/starters/examples/src/examples/abilities/missiles.ts +203 -0
  81. package/starters/examples/src/examples/abilities/missilesLooks.ts +38 -0
  82. package/starters/examples/src/examples/abilities/moves.ts +206 -0
  83. package/starters/examples/src/examples/abilities/movesLooks.ts +52 -0
  84. package/starters/examples/src/examples/abilities/recasts.ts +181 -0
  85. package/starters/examples/src/examples/abilities/recastsLooks.ts +41 -0
  86. package/starters/examples/src/examples/abilities/resources.ts +166 -0
  87. package/starters/examples/src/examples/abilities/resourcesLooks.ts +42 -0
  88. package/starters/examples/src/examples/abilities/summons.ts +296 -0
  89. package/starters/examples/src/examples/abilities/summonsLooks.ts +115 -0
  90. package/starters/examples/src/examples/abilities/triggers.ts +204 -0
  91. package/starters/examples/src/examples/abilities/triggersLooks.ts +53 -0
  92. package/starters/examples/src/examples/abilities/vision.ts +137 -0
  93. package/starters/examples/src/examples/abilities/visionLooks.ts +50 -0
  94. package/starters/examples/src/examples/animation/index.ts +235 -0
  95. package/starters/examples/src/examples/animation/looks.ts +444 -0
  96. package/starters/examples/src/examples/catalog.ts +52 -0
  97. package/starters/examples/src/examples/cinematics/index.ts +217 -0
  98. package/starters/examples/src/examples/cinematics/looks.ts +476 -0
  99. package/starters/examples/src/examples/cinematics/tour.json +43 -0
  100. package/starters/examples/src/examples/cinematics/village.ts +88 -0
  101. package/starters/examples/src/examples/common/looks.ts +65 -0
  102. package/starters/examples/src/examples/common/units.ts +70 -0
  103. package/starters/examples/src/examples/construction/index.ts +329 -0
  104. package/starters/examples/src/examples/construction/looks.ts +275 -0
  105. package/starters/examples/src/examples/custom-animation/bones.ts +92 -0
  106. package/starters/examples/src/examples/custom-animation/index.ts +190 -0
  107. package/starters/examples/src/examples/custom-animation/looks.ts +523 -0
  108. package/starters/examples/src/examples/custom-animation/runes.ts +82 -0
  109. package/starters/examples/src/examples/custom-fx/glaive.look.ts +110 -0
  110. package/starters/examples/src/examples/custom-fx/glaive.ts +40 -0
  111. package/starters/examples/src/examples/custom-fx/gpu.look.ts +145 -0
  112. package/starters/examples/src/examples/custom-fx/gpu.ts +22 -0
  113. package/starters/examples/src/examples/custom-fx/index.ts +25 -0
  114. package/starters/examples/src/examples/custom-fx/lights.look.ts +144 -0
  115. package/starters/examples/src/examples/custom-fx/lights.ts +25 -0
  116. package/starters/examples/src/examples/custom-fx/looks.ts +36 -0
  117. package/starters/examples/src/examples/custom-fx/meteor.look.ts +133 -0
  118. package/starters/examples/src/examples/custom-fx/meteor.ts +26 -0
  119. package/starters/examples/src/examples/custom-fx/parts.look.ts +193 -0
  120. package/starters/examples/src/examples/custom-fx/parts.ts +21 -0
  121. package/starters/examples/src/examples/custom-fx/portal.look.ts +193 -0
  122. package/starters/examples/src/examples/custom-fx/portal.ts +48 -0
  123. package/starters/examples/src/examples/custom-fx/renderer.look.ts +139 -0
  124. package/starters/examples/src/examples/custom-fx/renderer.ts +18 -0
  125. package/starters/examples/src/examples/custom-fx/ribbons.look.ts +151 -0
  126. package/starters/examples/src/examples/custom-fx/ribbons.ts +22 -0
  127. package/starters/examples/src/examples/custom-fx/shader.ts +42 -0
  128. package/starters/examples/src/examples/custom-fx/shield.look.ts +226 -0
  129. package/starters/examples/src/examples/custom-fx/shield.ts +43 -0
  130. package/starters/examples/src/examples/define.ts +86 -0
  131. package/starters/examples/src/examples/destruction/index.ts +570 -0
  132. package/starters/examples/src/examples/destruction/looks.ts +447 -0
  133. package/starters/examples/src/examples/dialogs/index.ts +383 -0
  134. package/starters/examples/src/examples/dialogs/looks.ts +546 -0
  135. package/starters/examples/src/examples/dynamic-map/index.ts +603 -0
  136. package/starters/examples/src/examples/dynamic-map/looks.ts +301 -0
  137. package/starters/examples/src/examples/fx/blood.look.ts +62 -0
  138. package/starters/examples/src/examples/fx/blood.ts +59 -0
  139. package/starters/examples/src/examples/fx/feel.look.ts +86 -0
  140. package/starters/examples/src/examples/fx/feel.ts +57 -0
  141. package/starters/examples/src/examples/fx/fight.ts +28 -0
  142. package/starters/examples/src/examples/fx/floats.look.ts +108 -0
  143. package/starters/examples/src/examples/fx/floats.ts +24 -0
  144. package/starters/examples/src/examples/fx/gallery.look.ts +168 -0
  145. package/starters/examples/src/examples/fx/gallery.ts +23 -0
  146. package/starters/examples/src/examples/fx/index.ts +30 -0
  147. package/starters/examples/src/examples/fx/links.look.ts +83 -0
  148. package/starters/examples/src/examples/fx/links.ts +49 -0
  149. package/starters/examples/src/examples/fx/looks.ts +55 -0
  150. package/starters/examples/src/examples/fx/map.look.ts +93 -0
  151. package/starters/examples/src/examples/fx/map.ts +33 -0
  152. package/starters/examples/src/examples/fx/marks.look.ts +100 -0
  153. package/starters/examples/src/examples/fx/marks.ts +36 -0
  154. package/starters/examples/src/examples/fx/shots.look.ts +92 -0
  155. package/starters/examples/src/examples/fx/shots.ts +71 -0
  156. package/starters/examples/src/examples/fx/spells.look.ts +87 -0
  157. package/starters/examples/src/examples/fx/spells.ts +122 -0
  158. package/starters/examples/src/examples/fx/stage.ts +31 -0
  159. package/starters/examples/src/examples/gui/babylonScreen.ts +167 -0
  160. package/starters/examples/src/examples/gui/babylonWorld.ts +191 -0
  161. package/starters/examples/src/examples/gui/htmlPart.ts +95 -0
  162. package/starters/examples/src/examples/gui/index.ts +143 -0
  163. package/starters/examples/src/examples/gui/keys.ts +91 -0
  164. package/starters/examples/src/examples/gui/layout.ts +42 -0
  165. package/starters/examples/src/examples/gui/looks.ts +270 -0
  166. package/starters/examples/src/examples/gui/minimap.ts +127 -0
  167. package/starters/examples/src/examples/gui/ownInterface.ts +129 -0
  168. package/starters/examples/src/examples/gui/plates.ts +87 -0
  169. package/starters/examples/src/examples/gui/radial.ts +104 -0
  170. package/starters/examples/src/examples/gui/solidPart.ts +107 -0
  171. package/starters/examples/src/examples/looks.ts +45 -0
  172. package/starters/examples/src/examples/mechanics/battle.ts +51 -0
  173. package/starters/examples/src/examples/mechanics/damage.ts +80 -0
  174. package/starters/examples/src/examples/mechanics/flying.ts +93 -0
  175. package/starters/examples/src/examples/mechanics/formations.ts +58 -0
  176. package/starters/examples/src/examples/mechanics/highGround.ts +82 -0
  177. package/starters/examples/src/examples/mechanics/index.ts +44 -0
  178. package/starters/examples/src/examples/mechanics/looks.ts +289 -0
  179. package/starters/examples/src/examples/mechanics/maze.ts +119 -0
  180. package/starters/examples/src/examples/mechanics/orders.ts +68 -0
  181. package/starters/examples/src/examples/mechanics/selection.ts +30 -0
  182. package/starters/examples/src/examples/moba/index.ts +377 -0
  183. package/starters/examples/src/examples/moba/looks.ts +349 -0
  184. package/starters/examples/src/examples/moba/maps.ts +69 -0
  185. package/starters/examples/src/examples/moba/spells.ts +34 -0
  186. package/starters/examples/src/examples/moba/units.ts +196 -0
  187. package/starters/examples/src/examples/nature/camera.ts +15 -0
  188. package/starters/examples/src/examples/nature/grass.ts +64 -0
  189. package/starters/examples/src/examples/nature/index.ts +327 -0
  190. package/starters/examples/src/examples/nature/land.ts +173 -0
  191. package/starters/examples/src/examples/nature/looks.ts +577 -0
  192. package/starters/examples/src/examples/nature/maps.ts +370 -0
  193. package/starters/examples/src/examples/nature/skies.ts +324 -0
  194. package/starters/examples/src/examples/nature/weather.ts +80 -0
  195. package/starters/examples/src/examples/performance/index.ts +263 -0
  196. package/starters/examples/src/examples/performance/looks.ts +372 -0
  197. package/starters/examples/src/examples/performance/meter.ts +158 -0
  198. package/starters/examples/src/examples/post/grades.ts +86 -0
  199. package/starters/examples/src/examples/post/index.ts +202 -0
  200. package/starters/examples/src/examples/post/looks.ts +884 -0
  201. package/starters/examples/src/examples/post/passes.ts +213 -0
  202. package/starters/examples/src/examples/rts/ai.ts +49 -0
  203. package/starters/examples/src/examples/rts/airlift.ts +43 -0
  204. package/starters/examples/src/examples/rts/base.ts +129 -0
  205. package/starters/examples/src/examples/rts/build.ts +65 -0
  206. package/starters/examples/src/examples/rts/camps.ts +36 -0
  207. package/starters/examples/src/examples/rts/carry.ts +103 -0
  208. package/starters/examples/src/examples/rts/creeps.ts +61 -0
  209. package/starters/examples/src/examples/rts/data.ts +406 -0
  210. package/starters/examples/src/examples/rts/ferry.ts +41 -0
  211. package/starters/examples/src/examples/rts/garrison.ts +50 -0
  212. package/starters/examples/src/examples/rts/harvest.ts +29 -0
  213. package/starters/examples/src/examples/rts/hero.ts +67 -0
  214. package/starters/examples/src/examples/rts/index.ts +77 -0
  215. package/starters/examples/src/examples/rts/looks.ts +721 -0
  216. package/starters/examples/src/examples/rts/shop.ts +46 -0
  217. package/starters/examples/src/examples/rts/tech.ts +39 -0
  218. package/starters/examples/src/examples/rts/train.ts +48 -0
  219. package/starters/examples/src/examples/rts/waterside.ts +185 -0
  220. package/starters/examples/src/examples/showcase/index.ts +714 -0
  221. package/starters/examples/src/examples/showcase/looks.ts +238 -0
  222. package/starters/examples/src/game.ts +17 -0
  223. package/starters/examples/src/main.ts +82 -0
  224. package/starters/examples/src/rules.ts +31 -0
  225. package/starters/examples/src/shell/look.ts +79 -0
  226. package/starters/examples/src/shell/registry.ts +7 -0
  227. package/starters/examples/src/shell/scenes.ts +178 -0
  228. package/starters/examples/src/shell/shell.ts +290 -0
  229. package/starters/examples/src/shell/style.ts +202 -0
  230. package/starters/examples/src/view.ts +50 -0
  231. package/starters/melee/README.md +12 -3
  232. package/starters/melee/layers/tests/tests/heroes.test.ts +402 -0
  233. package/starters/melee/layers/tests/tests/rules.test.ts +10 -8
  234. package/starters/melee/src/game.ts +32 -2
  235. package/starters/melee/src/maps/bay.names.ts +2 -2
  236. package/starters/melee/src/maps/bay.vgmap.json +198 -1
  237. package/starters/melee/src/maps.ts +11 -9
  238. package/starters/melee/src/melee/abilities.ts +1 -194
  239. package/starters/melee/src/melee/blight.ts +7 -7
  240. package/starters/melee/src/melee/cellLayers.json +3 -3
  241. package/starters/melee/src/melee/data.json +1 -18
  242. package/starters/melee/src/melee/game.ts +6 -4
  243. package/starters/melee/src/melee/heroes.ts +1025 -0
  244. package/starters/melee/src/melee/index.ts +64 -61
  245. package/starters/melee/src/melee/items.ts +15 -362
  246. package/starters/melee/src/melee/languages/he.json +14 -2
  247. package/starters/melee/src/melee/pings.ts +5 -6
  248. package/starters/melee/src/melee/saves.ts +1 -1
  249. package/starters/melee/src/melee/undead.ts +15 -230
  250. package/starters/melee/src/melee/units.ts +37 -171
  251. package/starters/melee/src/melee/view/attacks.ts +19 -0
  252. package/starters/melee/src/melee/view/heroSpellLooks.ts +39 -8
  253. package/starters/melee/src/melee/view/index.ts +56 -52
  254. package/starters/melee/src/melee/view/looks.ts +86 -63
  255. package/starters/melee/src/melee/view/undeadLooks.ts +70 -31
  256. package/starters/melee/src/melee/view/walls.ts +1 -1
  257. package/starters.mjs +6 -0
  258. package/v12.mjs +14 -15
  259. package/starters/campaign/src/rules.ts +0 -20
  260. package/starters/empty/src/maps.ts +0 -44
  261. package/starters/empty/src/tiles.ts +0 -12
  262. package/starters/melee/src/melee/heroSpells.ts +0 -38
  263. 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