diff --git a/.gitignore b/.gitignore index b947077..939e822 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ +build/ dist/ diff --git a/Makefile b/Makefile index a870b61..3332948 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ # # make list the targets # make setup install the dependencies +# make check typecheck, lint and both test suites # make dist package for this machine # make release package and publish to Gitea # @@ -23,8 +24,8 @@ TAG ?= v$(VERSION) .DEFAULT_GOAL := help -.PHONY: help setup node-check start smoke uitest test dist dist-mac dist-win dist-linux \ - release publish clean distclean version +.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest test \ + dist dist-mac dist-win dist-linux release publish clean distclean version help: ## List available targets @echo "WarpEngine Store GUI $(VERSION) — usage: make " @@ -45,6 +46,22 @@ node-check: ## Check the Node version Electron's installer needs setup: node-check ## Install the dependencies npm install +build: ## Compile TypeScript and bundle the preload and the renderer + npm run build + +typecheck: ## Type-check everything, emitting nothing + npm run typecheck + +lint: ## Lint with the strict rule set + npm run lint + +lint-fix: ## Lint and fix what can be fixed automatically + npm run lint:fix + +# The order is deliberate: a type error explains a lint error, and both explain a +# failing test, so the cheapest check that can fail runs first. +check: typecheck lint test ## Type-check, lint, and run both test suites + start: ## Run the app against whatever store is installed npm start @@ -75,8 +92,8 @@ publish: ## Upload the packages already in dist/ to the Gitea release # what this version produced. release: clean dist publish ## Package for this machine and publish it -clean: ## Remove the built packages - rm -rf dist +clean: ## Remove the compiled output and the built packages + rm -rf build dist distclean: clean ## Remove the packages and the dependencies rm -rf node_modules @@ -86,5 +103,7 @@ version: ## Show the versions involved @printf "node "; node --version 2>/dev/null || echo "missing" @printf "npm "; npm --version 2>/dev/null || echo "missing" @printf "electron "; node -p "require('./package.json').devDependencies.electron" 2>/dev/null || echo "missing" + @printf "typescript "; npx tsc --version 2>/dev/null || echo "missing" + @printf "eslint "; npx eslint --version 2>/dev/null || echo "missing" @printf "tea "; tea --version 2>/dev/null | head -1 || echo "missing — devarea: make tea" @printf "python3 "; python3 --version 2>/dev/null || echo "missing" diff --git a/README.md b/README.md index 9d0f6ef..8324806 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ catalog it serves and where its configuration lives. - Nothing else at runtime. No Node, no package manager, no admin rights: the store installs under your own user account. +To *develop* it you also need **Node 22 or newer** — see below — and nothing else: the +toolchain (TypeScript, ESLint, esbuild, electron-builder) installs with `make setup`. + ## Install Grab the package for your machine from the @@ -145,19 +148,24 @@ names. `make` on its own lists everything. | Target | What it does | |---|---| | `make setup` | install the dependencies (checks the Node version first) | +| `make build` | compile TypeScript, bundle the preload and the renderer | +| `make typecheck` | type-check everything, emitting nothing | +| `make lint` | the strict rule set (`lint-fix` fixes what it can) | +| `make check` | **typecheck, lint and both test suites** — the gate | | `make start` | run the app against whatever store is installed | -| `make smoke` | drive the store bridge with no window at all | +| `make smoke` | drive the store with no window and no Electron at all | | `make uitest` | load the window once and report what rendered | | `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file | -| `make test` | both checks | +| `make test` | both test suites | | `make dist` | package for this machine (`dist-mac`, `dist-win`, `dist-linux` to pick) | | `make publish` | upload the packages already in `dist/` to the Gitea release | | `make release` | **package and publish in one go** | -| `make clean` | remove the built packages (`distclean` also drops `node_modules`) | +| `make clean` | remove `build/` and the packages (`distclean` also drops `node_modules`) | | `make version` | the versions involved, including whether `tea` is there | The npm scripts still work directly (`npm start`, `npm run dist:mac`) — the -Makefile adds no logic of its own beyond the release step. +Makefile adds no logic of its own beyond the release step. Every script that runs the +app builds first, so there is no way to test a stale bundle. ### Publishing a release @@ -210,33 +218,52 @@ SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke ### How it is put together -| File | What it does | +TypeScript, in layers, with the dependency rule pointing inward. **[STRUCTURE.md](STRUCTURE.md) +is the map** — the layers, every pattern in use, and the naming rules. The short version: + +| Layer | What lives there | |---|---| -| `main.js` | the window, the IPC, and the one-call-at-a-time guard | -| `preload.js` | the entire surface the renderer gets — no Node reaches it | -| `lib/store.js` | finds the store and Python, runs the CLI, parses its JSON | -| `lib/bootstrap.js` | reads the registry, then downloads the engine, the core and a config | -| `lib/i18n.js` | the two string tables | -| `renderer/` | plain HTML, CSS and JS — no framework, no build step | -| `Makefile` | the named sequences; no logic of its own beyond the release | +| `src/shared/` | the IPC channel table, the bridge contract, the DTOs, the two message bundles | +| `src/domain/` | models, ports and errors — no Electron, no Node, no Python | +| `src/application/` | services and the domain → DTO mappers | +| `src/infrastructure/` | the adapters: the Python CLI, HTTP, the filesystem, Electron itself | +| `src/main/` | the window, the IPC controllers, the composition root, the self-test | +| `src/preload/` | the bridge, bundled into one file — a sandboxed preload cannot require modules | +| `src/renderer/` | the state store, the views and the renderer controllers | +| `src/scripts/` | the smoke test: the same services with no window at all | | `scripts/release.sh` | creates the Gitea release and replaces its attachments | | `scripts/after-pack.js` | ad-hoc signs the macOS bundle during packaging | +| `scripts/build-assets.mjs` | bundles the preload and the renderer, copies the page | -`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page -carries a CSP that allows only its own script and stylesheet plus images over -HTTPS. Links open in the real browser; the window itself never navigates. +Two properties are worth stating because they are what the layers buy: -`lib/store.js` talks to the CLI through `--json`, which puts data on stdout and -the human-readable log on stderr. That flag arrived with engine **1.1.0**, and the -client checks: an older store is met with an offer to refresh it rather than a -failed call. +- **The catalog can be driven without a window.** `make smoke` assembles the same + services against the same ports with no Electron in the process at all. +- **The window never receives a filesystem path.** A `GameDto` carries no paths; the + window asks to launch a title *by name* and the main process resolves what that means + from the store's own state. -The bridge keeps an `ENGINES` list with one entry today. The RetroArch store has -the same command shape, so a second entry is the whole change needed to drive it -too — that is why the indirection is there. +`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page carries a +CSP that allows only its own script and stylesheet plus images over HTTPS. Links open +in the real browser; the window itself never navigates. + +`PythonStoreCatalogGateway` is the only class that knows the store is a Python +program. It talks to the CLI through `--json`, which puts data on stdout and the +human-readable log on stderr. That flag arrived with engine **1.1.0**, and the client +checks: an older store is met with an offer to refresh it rather than a failed call. + +`STORE_ENGINES` has one entry today. The RetroArch store has the same command shape, +so a second entry is the whole change needed to drive it too — that is why the table is +there. ## Verified, and not +The 1.3.0 refactor was measured rather than trusted: `make check` is clean — no type +errors, no lint findings, both test suites green — the window was photographed before +and after and the two are the same picture, and the packaged 1.3.0 bundle was run from +`dist/` and drove the real store. The published package contains `build/` and +`package.json` and nothing else: 111 entries, no sources, no toolchain. + Exercised on macOS (arm64), with the packaged app from the release rather than a dev run: the store is discovered, the catalog lists, a sync installs, the window renders the installed state, and `npm run uitest` passes with the grid rendered diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c7dc855..73b9b65 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,32 +1,42 @@ -# WarpEngine Store 1.2.0 +# WarpEngine Store 1.3.0 -**A side menu.** Everything that is not a title moved out of the bar into a menu on -the left that folds away with `☰`: the stores on this machine, the two actions, the -categories and the language. Open or closed is remembered between runs. +**TypeScript, in layers.** The client was one `main.js`, one `preload.js`, three files +in `lib/` and one renderer script. It is now a typed application with the dependency +rule pointing inward: `domain` (models, ports, errors) knows nothing about Electron, +Node or Python; `application` orchestrates it through those ports; `infrastructure` +holds the adapters — the Python CLI, HTTP, the filesystem, Electron itself — and the +hosts (`main`, `preload`, `renderer`) sit on top. **[STRUCTURE.md](STRUCTURE.md)** is +the map: every layer, every pattern in use, and the naming rules, written to be read +before adding anything. -**Categories.** The grid narrows to *Installed*, *Updates* or *Not installed*, to a -platform (`godot`, `tic80`, `love`, …), or to native/hosted titles — one at a time, -each with its count. The axes are built from what the catalog actually contains, so -nothing empty is listed, and a category that disappears under you falls back to -*Everything* rather than leaving a blank grid. +Nothing about the window changed. Same side menu, same categories, same switcher, same +two languages — this release is the inside of the app. -**Switching stores.** With more than one store installed, clicking another in the -menu opens it: the grid, the categories and the folders follow, and the client -reopens on the store last used. Two stores installed from the same catalog into -different folders are told apart by their folder, since their id is identical. +Two properties came out of the move, and both are worth having: -While the store is working, the menu, the log drawer and the filters keep working — -only what would start a second call is disabled. +- **The catalog can be driven with no window and no Electron at all.** `make smoke` + assembles the same services against the same ports in a plain Node process. It was a + script that reimplemented the bridge before; now it is a second composition root. +- **The window never receives a filesystem path.** A title crosses the bridge without + one, and launching is asked for *by name* — the main process resolves what that means + from the store's own state. Nothing in the renderer can be talked into opening a path. -**The grid was broken, and now is not.** Its rows split the window's height evenly -rather than following their content, so every card came out 94px tall: the box art -collapsed to nothing and the action buttons were clipped away below the fold. The -DOM was intact the whole time — ten cards, twenty buttons — which is why every -automated count passed. Cards now carry a band of art of one height, with the -title's first letter where the catalog has no image. +**A strict linter, and types everywhere.** `strict` plus +`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`, +`noPropertyAccessFromIndexSignature` and friends; typescript-eslint's +`strictTypeChecked` and `stylisticTypeChecked` sets; explicit return types, parameter +types and property types required even where inference would manage; exhaustive +switches; no `any`, no `!`, no casts on foreign data — engine output and the registry +go through readers that turn `unknown` into typed values. The naming patterns are +enforced by `naming-convention` rather than trusted. -Unchanged from 1.1.0: which stores exist is the site's answer (`GET /api/stores`), -not something baked into this app, and `STORES_API` overrides that address. +Two things the types now catch that a person used to: a translation with a missing key +does not compile, and a channel the preload does not implement does not compile. + +**New make targets:** `make build`, `make typecheck`, `make lint`, `make lint-fix` and +`make check` — the gate, which runs the type-check, the linter and both test suites in +that order, cheapest failure first. Every script that runs the app builds first, so a +stale bundle cannot be tested. ### Opening it on macOS @@ -37,22 +47,17 @@ xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app" ``` *Open Anyway* under **System Settings ▸ Privacy & Security** works as well. -Nothing the store itself downloads is affected — Python fetches those, and Python -does not set the quarantine flag. ### What is attached -**macOS arm64 only**, the machine this was built and verified on. Windows and -Linux packages need a build on those platforms (`make dist-win` / `dist-linux`). +**macOS arm64 only**, the machine this was built and verified on. Windows and Linux +packages need a build on those platforms (`make dist-win` / `make dist-linux`). ### Verified -`SELFTEST_SHOT=shot.png npm run uitest` has the window photograph itself, which is -how the collapsed rows were found and how the fix was confirmed — in English and in -Hungarian, with the menu open and closed. - -`npm run uitest` loads the window and reports what rendered; with two stores in one -root — a sandbox copy beside the real install — it now also clicks the store that -is not open and checks that the bar, the grid and the categories follow. Both runs -pass, and `npm run smoke` drives the same bridge with no window at all: ten titles, -five native and five hosted, every installed one with something to launch. +`make check` is clean: no type errors, no lint findings, the smoke test green against +the real store and against a sandbox one, and the window test green with one store and +with two — where it clicks the store that is not open and checks that the bar, the grid +and the categories follow. The window was photographed before and after the refactor +and the two are the same picture. The packaged app was run from the built bundle, not +from a dev launch. diff --git a/STRUCTURE.md b/STRUCTURE.md new file mode 100644 index 0000000..3df3e39 --- /dev/null +++ b/STRUCTURE.md @@ -0,0 +1,289 @@ +# Structure + +This is the map of the client: which layer may know about which, what every kind of +class is called, and which pattern is used where. It is written to be read before +adding anything — the point of the layout is that a new feature has an obvious place. + +The application drives a program that **installs and deletes files**. That is why the +rules below are strict rather than tasteful: an implicit `any` or a filesystem path +that reaches the window is a safety question, not a style one. + +## The layers + +``` +shared ← contracts and strings both sides need (no logic, no I/O) +domain ← models, ports, errors. Knows nothing about Electron, Node or Python +application ← services and DTO mappers. Orchestrates the domain through its ports +infrastructure ← adapters: the Python CLI, HTTP, the filesystem, Electron itself +main ← the Electron host: window, IPC controllers, composition root +preload ← the bridge, and only the bridge +renderer ← the window: state store, views, controllers +``` + +**The dependency rule: imports point inward.** `domain` imports nothing but `shared`. +`application` imports `domain` and `shared`. `infrastructure` implements `domain` +ports. `main`, `preload` and `renderer` are hosts: they may import inward, and nothing +imports them. There is no barrel file and no `index.ts` re-export — every import names +the module it needs, so a cycle is visible in the diff that creates it. + +Two consequences worth stating, because they are the reason the layout pays for +itself: + +- **`domain` and `application` never import `electron`.** The smoke test assembles the + same services with no Electron at all (`src/scripts/SmokeTest.ts`), which is how the + catalog is exercised in a terminal. +- **The renderer never receives a filesystem path it could act on.** `GameDto` has no + paths; a launch is asked for by name and resolved in the main process. + +## The tree + +``` +src/ + shared/ + contracts/ + IpcChannels.ts every channel name, frozen, in one table + BridgeApi.ts the whole surface the window gets + dto/ what crosses the bridge: plain, JSON-safe data + i18n/ + EnglishMessages.ts the key set, and the English bundle + HungarianMessages.ts typed against those keys + MessageBundle.ts MessageBundle, Locale, LOCALES + TranslationCatalog.ts locale resolution and bundle lookup + domain/ + models/ Game, InstalledStore, RegistryStore, StorePaths, … + ports/ the interfaces the application depends on + errors/ DomainError and its subclasses, each with a code + application/ + services/ CatalogService, StoreSelectionService, … + mappers/ domain → DTO + infrastructure/ + process/ Python: locating it, running it, reading its streams + repositories/ the port implementations + mappers/ engine JSON → domain + http/ HttpTextClient, HttpStatusError + json/ JsonRecord: reading data that came from elsewhere + electron/ ApplicationEnvironment and GameLauncher adapters + main/ + main.ts the entry point: one line of work + ElectronApplication.ts lifecycle, single instance, self-test mode + MainWindowFactory.ts the window and its security settings + composition/ ServiceContainer: the composition root + ipc/ IpcRouter, the controllers, the guard, argument readers + streams/ WindowStreamBroadcaster: the three one-way streams + diagnostics/ SelfTestRunner + preload/ + preload.ts implements BridgeApi over ipcRenderer + renderer/ + main.ts the entry point + RendererApplication.ts wires views and controllers, owns the boot decision + BridgeAccess.ts the typed window.storeApi + state/ AppStore, CategoryFilter + views/ one class per region of the window + controllers/ one class per group of actions + dom/ Dom.ts: the DOM chores + index.html, style.css copied into the build as-is + scripts/ + SmokeTest.ts the second composition root, with no window +``` + +## Patterns + +Every pattern in the codebase is listed here. If a change needs a pattern that is not +on this list, it belongs on this list. + +### Ports and adapters + +`domain/ports/*` are interfaces; `infrastructure/*` implements them; the composition +root is the only file that knows which implementation is in use. This is what makes +the Python CLI, the registry HTTP call and Electron's `shell` replaceable — by a stub +in a test, by a local endpoint in development, by a second engine later. + +### Repository and Gateway + +Both are ports; the distinction is what is behind them. + +- **Repository** — a store of records this application owns the shape of: + `InstalledStoreRepository`, `PreferencesRepository`, `StoreRegistryRepository`. +- **Gateway** — another program or service with its own protocol: + `StoreCatalogGateway` (the engine). + +### Service + +`application/services/*` — one service per area of behaviour, no HTTP, no `fs`, no +`child_process`. A service may depend on ports and on other services, never on a +controller or a view. + +### DTO and Mapper + +Data crossing a boundary is a DTO, and a mapper converts. Two boundaries, two +directions: + +- `infrastructure/mappers/Engine*Mapper` — engine JSON (snake_case) → domain model. + These are the only files that know the engine's field names. +- `application/mappers/*DtoMapper` — domain model → DTO for the bridge. Decisions the + window must not make live here: the absolute box-art URL, whether a title can be + launched at all. + +### Composition root + +`main/composition/ServiceContainer.ts` for the application, `scripts/SmokeTest.ts` for +the headless check. Wiring happens in exactly these two places. No service constructs +its own adapter, and there is no service locator or global registry — dependencies +arrive through constructors. + +### Controller and Router + +`main/ipc/*IpcController` register their channels on `IpcRouter` and translate a +channel invocation into one service call. They validate their arguments +(`IpcArguments.ts`) and map results through DTO mappers. The router normalises errors +so a `DomainError` crosses as `CODE: message`. + +Renderer controllers (`renderer/controllers/*`) are the mirror image: a user action +becomes one bridge call and one write to the state store. + +### Single flight + +`SingleFlightGuard` — one engine call at a time, because the store writes files and +two writers would race. It reports its state, which is what lets the window disable +exactly the controls that would start a second call and leave the filters and the log +alive. + +### Observer streams + +Main pushes three one-way streams — log lines, progress events, busy state — through +`WindowStreamBroadcaster`, which the engine sees as an `EngineProgressListener`. The +renderer subscribes once, in `EngineStreamController`. + +### State store and unidirectional flow + +`renderer/state/AppStore.ts` holds the whole window state. Every mutator is named +after what it changes and notifies afterwards; `RendererApplication` re-renders every +view from the new state. Views never read each other and never hold state, so a +listing can be thrown away and rebuilt. + +### Passive view + +`renderer/views/*` — a view takes its DOM nodes and callbacks in the constructor and +has one `render(state)` method. It contains no decisions beyond presentation, and it +never calls the bridge. + +### Error hierarchy with codes + +`DomainError` is abstract with a `code`; subclasses name a single failure +(`PythonMissingError`, `StoreMissingError`, `EngineInvocationError`, +`RegistryUnavailableError`, `BusyError`). The code is what crosses the bridge. + +### Frozen constant tables + +`IPC_CHANNELS`, `STORE_ENGINES`, the message bundles: `as const` tables with a derived +type, so a typo is a compile error and adding an entry is the whole change. This is +the extension point for a second engine. + +### Untrusted-data readers + +Anything parsed from outside — engine stdout, the registry — goes through +`infrastructure/json/JsonRecord.ts`: `unknown` in, a typed value with a stated +fallback out. No `as` casts on foreign data. + +## Naming + +The names are a pattern, not a preference, and are checked by +`@typescript-eslint/naming-convention` where a linter can check them. + +### Files + +- One primary export per file; the filename is the subject in `PascalCase` + (`CatalogService.ts`, `GameDto.ts`). +- A file whose primary export is a constant table is named for the table, and the + export is its `UPPER_SNAKE_CASE` form (`IpcChannels.ts` exports `IPC_CHANNELS`). +- Directories are lowercase and plural where they hold several of a kind (`models`, + `ports`, `views`, `services`). + +### Types and classes + +| Kind | Pattern | Example | +|---|---|---| +| Domain model | plain noun, no suffix | `Game`, `InstalledStore` | +| Port | `Repository` / `Gateway` / `Locator` / `Installer` / `Launcher` | `StoreCatalogGateway` | +| Adapter | `` | `PythonStoreCatalogGateway`, `HttpStoreRegistryRepository`, `FileSystemInstalledStoreRepository` | +| Service | `Service` | `CatalogService` | +| Mapper | `Mapper` / `DtoMapper` | `EngineGameMapper`, `GameDtoMapper` | +| Wire type | `Dto` | `CatalogListingDto` | +| IPC controller | `IpcController` | `CatalogIpcController` | +| Renderer controller | `Controller` | `StoreController` | +| View | `View` | `SideMenuView`, `GameCardView` | +| Factory | `Factory` | `MainWindowFactory` | +| Error | `Error` | `PythonMissingError` | +| Callback bag | `Callbacks` | `SideMenuViewCallbacks` | +| Type parameter | `T`-prefixed | `TResult`, `TElement` | + +Interfaces carry no `I` prefix: a port is named for what it does, and its +implementations say what they are made of. + +### Methods + +The verb states the contract, so a caller knows what a name will do before reading it. + +| Prefix | Contract | +|---|---| +| `find…` | returns the thing or `null` / an array; absence is normal | +| `require…` | returns the thing or **throws**; absence is a fault | +| `read…` | fetches from a store, a file or a process | +| `list…` | returns a collection from somewhere outside | +| `install…`, `sync…`, `remove…`, `select…`, `update…` | changes something | +| `apply…` | writes to the renderer state store | +| `render…` | draws (views only) | +| `handle…` | an IPC or DOM event handler | +| `on…` | a callback property or subscription | +| `to…` / `from…` | a mapper conversion | +| `is…`, `has…`, `can…` | a boolean | +| `describe…` | turns something into a message for a person | + +Booleans read as assertions (`supported`, `installed`, `launchable`, `busy`), never +`flag` or `status`. + +## Type rules + +- `strict`, plus `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, + `noImplicitOverride`, `noImplicitReturns`, `noPropertyAccessFromIndexSignature`, + `noFallthroughCasesInSwitch`, `isolatedModules`. +- **Every signature is annotated** — parameters, return types, class properties — + including where inference would manage: `explicit-function-return-type`, + `explicit-module-boundary-types` and `typedef` are errors. +- Data is `readonly`: DTO and model fields, and arrays as `readonly T[]`. +- No `any`, no non-null `!`, no unchecked casts. Foreign data goes through + `JsonRecord`; DOM lookups go through `requireElement`, which checks the element type + it was asked for. +- Exhaustive `switch` over union types, checked by `switch-exhaustiveness-check` — the + sync-event union is handled that way on purpose. + +`erasableSyntaxOnly` is deliberately **off**: constructor parameter properties are how +dependencies are declared here, and that is worth more than being strippable by +`node --experimental-strip-types`. + +## How to add things + +**A new bridge call.** Add the channel to `IPC_CHANNELS`, the method to `BridgeApi`, +the implementation to `preload.ts`, a `handle…` method to the right controller, and the +behaviour to a service. The compiler names every file you missed. + +**A new engine (e.g. RetroArch).** Add an entry to `STORE_ENGINES`. The store +discovery, the home suffix and the launcher name all read from that table; the CLI has +the same command shape, so `PythonStoreCatalogGateway` is unchanged. + +**A new field from the engine.** `EngineGameMapper` reads it into the model, `GameDto` +and `GameDtoMapper` carry it across if the window needs it, and a view renders it. + +**A new language.** Add `Messages.ts` typed as `MessageBundle`, add the code +to `LOCALES` and the bundle to `TranslationCatalog`. A missing key will not compile. + +## Build layout + +`tsc` compiles the main process to CommonJS in `build/`. The preload and the renderer +are **bundled** by esbuild into one file each (`build/preload/preload.js`, +`build/renderer/app.js`), because a sandboxed preload may not require its own modules +and a module script over `file://` is blocked by the page's origin rules. `index.html` +and `style.css` are copied. `electron-builder` ships `build/**` and nothing else. + +`make check` is the gate: `typecheck`, `lint`, then the two test suites — the cheapest +check that can fail runs first. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..bea68b9 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,49 @@ +// Strict on purpose: this is a client that drives a program which deletes files, +// so an implicit `any` crossing a layer boundary is not a style question. +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['build/**', 'dist/**', 'node_modules/**', 'scripts/*.js', 'scripts/*.mjs', 'eslint.config.mjs'] }, + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } + }, + rules: { + // Types everywhere, including the ones TypeScript would happily infer: a + // signature is the layer's contract, and it should be readable without + // running the compiler in your head. + '@typescript-eslint/explicit-function-return-type': ['error', { allowExpressions: false }], + '@typescript-eslint/explicit-module-boundary-types': 'error', + '@typescript-eslint/typedef': ['error', { parameter: true, propertyDeclaration: true }], + // typedef and no-inferrable-types disagree about `fallback: string = ''`. The + // annotation wins: a signature states its types even where TypeScript could + // guess them. + '@typescript-eslint/no-inferrable-types': ['error', { ignoreParameters: true }], + '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], + '@typescript-eslint/prefer-readonly': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-unnecessary-condition': 'error', + '@typescript-eslint/switch-exhaustiveness-check': 'error', + + // The naming patterns STRUCTURE.md documents, enforced rather than trusted. + '@typescript-eslint/naming-convention': ['error', + { selector: 'default', format: ['camelCase'] }, + { selector: 'variable', format: ['camelCase', 'UPPER_CASE'] }, + { selector: 'parameter', format: ['camelCase'], leadingUnderscore: 'allow' }, + { selector: 'typeLike', format: ['PascalCase'] }, + { selector: 'enumMember', format: ['UPPER_CASE'] }, + { selector: 'objectLiteralProperty', format: null }, + { selector: 'typeProperty', format: ['camelCase'] }, + { selector: 'classProperty', modifiers: ['static', 'readonly'], format: ['UPPER_CASE'] }, + { selector: 'classMethod', format: ['camelCase'] }, + { selector: 'function', format: ['camelCase'] } + ], + + 'no-console': 'off', + curly: ['error', 'multi-line'], + eqeqeq: ['error', 'always'] + } + } +) diff --git a/lib/bootstrap.js b/lib/bootstrap.js deleted file mode 100644 index ecf1294..0000000 --- a/lib/bootstrap.js +++ /dev/null @@ -1,159 +0,0 @@ -'use strict' -// Setting up a store when there is none yet. -// -// This is the reason the client exists on Windows at all: the shell installer is -// `curl … | sh`, which Windows does not have. The three files it would place are -// downloaded here instead, into the very same store home — so the CLI and the -// client stay one installation, and running install.sh afterwards only adds the -// launcher script. -// -// Which store, though, is not baked in. The client asks a registry — `GET -// /api/stores` on the site — and each record says what the store is called, which -// catalog it serves and where its configuration lives. A different site, or a -// second store on ours, needs no change here. The registry address is the one -// address the client does know, and even that is overridable. - -const fs = require('node:fs') -const https = require('node:https') -const http = require('node:http') -const path = require('node:path') - -// Where the engine itself comes from. Not part of the registry: this is the -// client's own machinery, the same for every store it can drive. -const FORGE = process.env.FORGE_BASE || 'https://git.teletypegames.org' -const ENGINE_SOURCES = { - 'desktop_store.py': `${FORGE}/stores/warp-engine-desktop-store/raw/branch/master/desktop_store.py`, - 'warpstore.py': `${FORGE}/engines/warpstore/raw/branch/master/warpstore.py` -} - -// The registry. One address, and the only thing about a particular site left in -// the client. -const REGISTRY_URL = process.env.STORES_API || 'https://teletypegames.org/api/stores' - -/** GET a URL as a string, following redirects — a moved repo answers 301. */ -function fetchText (url, redirects = 5) { - return new Promise((resolve, reject) => { - const client = url.startsWith('http://') ? http : https - const request = client.get(url, { headers: { 'User-Agent': 'warp-engine-desktop-gui' } }, (res) => { - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - res.resume() - if (redirects <= 0) return reject(new Error(`too many redirects for ${url}`)) - return fetchText(new URL(res.headers.location, url).toString(), redirects - 1).then(resolve, reject) - } - if (res.statusCode !== 200) { - res.resume() - const error = new Error(`${url} answered ${res.statusCode}`) - error.statusCode = res.statusCode - return reject(error) - } - let body = '' - res.setEncoding('utf8') - res.on('data', (chunk) => { body += chunk }) - res.on('end', () => resolve(body)) - }) - request.setTimeout(60000, () => request.destroy(new Error(`${url} timed out`))) - request.on('error', reject) - }) -} - -/** - * The stores this client can install, from the site's registry. - * - * Each record carries a name, the catalog it serves and the repository holding - * its config. Anything without those three is dropped rather than half-used. - */ -async function registry (url = REGISTRY_URL) { - const body = await fetchText(url) - const rows = JSON.parse(body) - if (!Array.isArray(rows)) throw new Error(`${url} did not answer with a list of stores`) - return rows - .map((row) => ({ - name: String(row.name || '').trim(), - catalogUrl: String(row.catalogUrl || row.catalog_url || '').trim(), - storeRepositoryUrl: String(row.storeRepositoryUrl || row.store_repository_url || '').trim() - })) - .filter((row) => row.name && row.catalogUrl && row.storeRepositoryUrl) -} - -/** `…/stores/ttg-desktop-store` -> the raw config.json on its default branch. */ -function configUrl (repositoryUrl, branch = 'master') { - return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/config.json` -} - -/** - * A store id from its repository name: `ttg-desktop-store` -> `ttg`. - * - * The id names the store home and the folder the games land in, so it has to be - * short and filesystem-safe. The repository name is the best source we have; the - * store's own config.json overrides it whenever it exists. - */ -function storeId (store) { - const last = store.storeRepositoryUrl.replace(/\/+$/, '').split('/').pop() || '' - const base = last.replace(/-(desktop-)?store$/, '') || store.name - return base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'store' -} - -/** - * The store's configuration. - * - * Its repository is the authority on how the store behaves — which platforms, - * which statuses, where things land. A repository without a config.json still - * works: the engine merges whatever it is given onto its own defaults, so a - * three-field config is a complete one. - */ -async function storeConfig (store, { onLog = () => {} } = {}) { - const id = storeId(store) - let config = null - try { - onLog(`reading the store config from ${store.storeRepositoryUrl}`) - config = JSON.parse(await fetchText(configUrl(store.storeRepositoryUrl))) - } catch (err) { - if (err.statusCode !== 404) throw err - onLog('no config.json in the store repository — using the engine defaults') - config = { paths: { subfolder: id }, catalog: { statuses: ['released', 'archived', 'demo'] } } - } - // The registry is the authority on identity and on which catalog to read, so - // those two win over whatever the file says. - config.store = { ...(config.store || {}) } - config.store.id = config.store.id || id - config.store.name = store.name - config.store.base_url = store.catalogUrl - return config -} - -/** Where this store's home goes. */ -function homeFor (store, root) { - return path.join(root, `${storeId(store)}-desktop`) -} - -/** - * Install the engine, the shared core and the store's config into `home`. - * - * `onLog` reports each step, because on a slow line this takes a few seconds and - * silence looks like a hang. - */ -async function install (home, store, { onLog = () => {} } = {}) { - if (!store) throw new Error('no store was chosen') - fs.mkdirSync(home, { recursive: true }) - - for (const [file, url] of Object.entries(ENGINE_SOURCES)) { - onLog(`downloading ${file}`) - const body = await fetchText(url) - if (!body.startsWith('#!/usr/bin/env python3')) { - throw new Error(`${file} does not look like the store engine — refusing to install it`) - } - fs.writeFileSync(path.join(home, file), body, { mode: 0o755 }) - } - - const configPath = path.join(home, 'config.json') - const config = await storeConfig(store, { onLog }) - fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) - - onLog(`${store.name} is set up in ${home}`) - return { home, config: configPath, script: path.join(home, 'desktop_store.py'), id: config.store.id } -} - -module.exports = { - ENGINE_SOURCES, FORGE, REGISTRY_URL, - configUrl, fetchText, homeFor, install, registry, storeConfig, storeId -} diff --git a/lib/i18n.js b/lib/i18n.js deleted file mode 100644 index ba81c23..0000000 --- a/lib/i18n.js +++ /dev/null @@ -1,130 +0,0 @@ -'use strict' -// Two languages, the way the public site has them. The CLI and the docs stay -// English; this is the one end-user surface where Hungarian matters. -// -// Catalog text — titles, descriptions — is never translated here: it arrives -// from the store as it was published. - -const STRINGS = { - en: { - appName: 'WarpEngine Store', - syncAll: 'Install all', - refresh: 'Refresh', - install: 'Install', - update: 'Update', - play: 'Play', - open: 'Open', - remove: 'Remove', - installed: 'installed', - native: 'native', - hosted: 'hosted', - hostedHint: 'Opens in your browser — needs the network', - nativeHint: 'Installed on this machine — works offline', - updateAvailable: 'update available', - log: 'Log', - menu: 'Menu', - stores: 'Stores', - addStore: 'Add a store…', - switchFailed: 'That store could not be opened', - actions: 'Actions', - categories: 'Categories', - catAll: 'Everything', - catInstalled: 'Installed', - catUpdates: 'Updates', - catAvailable: 'Not installed', - catPlatform: 'Platform', - catMode: 'Kind', - language: 'Language', - noGames: 'No installable titles in the catalog.', - noMatch: 'Nothing in this category.', - setupTitle: 'Set up a store', - setupBody: 'No store on this machine yet. Pick one and it will be downloaded — the same files the shell installer would place, in the same folder.', - setupAction: 'Download the store', - setupWorking: 'Setting up…', - setupChoose: 'Store', - registryFailed: 'The list of stores could not be fetched', - registryEmpty: 'The list of stores came back empty. Nothing to install from yet.', - registryRetry: 'Try again', - oldEngineTitle: 'The store needs refreshing', - oldEngineBody: 'The store engine on this machine is older than this client can drive. Refreshing it downloads the current engine and keeps your settings and installed games.', - oldEngineAction: 'Refresh the store', - noPythonTitle: 'Python 3 is required', - noPythonBody: 'The store is a Python program, so Python 3 has to be installed. Install it, then reopen this window.', - pythonLink: 'python.org/downloads', - paths: 'Where things go', - openStoreFolder: 'Open the store folder', - openMenuFolder: 'Open the menu folder', - busy: 'Working…', - failed: 'failed', - removed: 'removed', - upToDate: 'Everything is up to date.', - of: 'of' - }, - hu: { - appName: 'WarpEngine Store', - syncAll: 'Mind telepítése', - refresh: 'Frissítés', - install: 'Telepítés', - update: 'Frissítés', - play: 'Indítás', - open: 'Megnyitás', - remove: 'Eltávolítás', - installed: 'telepítve', - native: 'natív', - hosted: 'hosztolt', - hostedHint: 'A böngészőben nyílik meg — internet kell hozzá', - nativeHint: 'Erre a gépre telepítve — internet nélkül is megy', - updateAvailable: 'frissítés elérhető', - log: 'Napló', - menu: 'Menü', - stores: 'Store-ok', - addStore: 'Store hozzáadása…', - switchFailed: 'Ez a store nem nyitható meg', - actions: 'Műveletek', - categories: 'Kategóriák', - catAll: 'Minden', - catInstalled: 'Telepítve', - catUpdates: 'Frissítés', - catAvailable: 'Nincs telepítve', - catPlatform: 'Platform', - catMode: 'Fajta', - language: 'Nyelv', - noGames: 'Nincs telepíthető cím a katalógusban.', - noMatch: 'Ebben a kategóriában nincs semmi.', - setupTitle: 'Store beállítása', - setupBody: 'Ezen a gépen még nincs store. Válassz egyet, és letöltöm — ugyanazokat a fájlokat, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.', - setupAction: 'Store letöltése', - setupWorking: 'Beállítás…', - setupChoose: 'Store', - registryFailed: 'A store-ok listája nem érhető el', - registryEmpty: 'A store-ok listája üresen jött vissza. Egyelőre nincs miből telepíteni.', - registryRetry: 'Újra', - oldEngineTitle: 'A store frissítésre vár', - oldEngineBody: 'A gépen lévő store-motor régebbi, mint amit ez a kliens vezérelni tud. A frissítés letölti a mostani motort, a beállításaid és a telepített játékok pedig megmaradnak.', - oldEngineAction: 'Store frissítése', - noPythonTitle: 'Python 3 kell hozzá', - noPythonBody: 'A store egy Python program, tehát Python 3 kell a gépre. Telepítsd, majd nyisd meg újra ezt az ablakot.', - pythonLink: 'python.org/downloads', - paths: 'Hova kerül', - openStoreFolder: 'Store könyvtár megnyitása', - openMenuFolder: 'Menü könyvtár megnyitása', - busy: 'Dolgozom…', - failed: 'hiba', - removed: 'eltávolítva', - upToDate: 'Minden naprakész.', - of: '/' - } -} - -const FALLBACK = 'en' - -function pick (locale) { - const short = String(locale || '').slice(0, 2).toLowerCase() - return STRINGS[short] ? short : FALLBACK -} - -function dict (locale) { - return STRINGS[pick(locale)] -} - -module.exports = { FALLBACK, STRINGS, dict, pick, languages: Object.keys(STRINGS) } diff --git a/lib/store.js b/lib/store.js deleted file mode 100644 index b04d2e4..0000000 --- a/lib/store.js +++ /dev/null @@ -1,258 +0,0 @@ -'use strict' -// The bridge to the store CLI. -// -// The CLI is the product; this file only finds it and talks to it. Every -// operation is `desktop_store.py --json …`, which puts data on stdout and its -// log on stderr — so nothing here parses a sentence meant for a person. -// -// Shaped for more than one engine on purpose: ENGINES is a list today with one -// entry, and the RetroArch store could be added without touching the callers. - -const { spawn, spawnSync } = require('node:child_process') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') - -const ENGINES = [ - { - id: 'desktop', - script: 'desktop_store.py', - // The installer names the store home `-desktop`, so the RetroArch - // engine can share the same root without sharing config.json and state.json. - homeSuffix: '-desktop', - launcherSuffix: '-desktop-store' - } -] - -/** The roots the shell installers use, in the same order they would. */ -function storeRoots () { - const home = os.homedir() - const roots = [] - if (process.env.STORE_ROOT) roots.push(process.env.STORE_ROOT) - if (process.env.XDG_DATA_HOME) { - roots.push(path.join(process.env.XDG_DATA_HOME, 'warp-engine-store')) - } - roots.push(path.join(home, '.local', 'share', 'warp-engine-store')) - if (process.platform === 'darwin') { - roots.push(path.join(home, 'Library', 'Application Support', 'warp-engine-store')) - } - if (process.platform === 'win32' && process.env.LOCALAPPDATA) { - roots.push(path.join(process.env.LOCALAPPDATA, 'warp-engine-store')) - } - return [...new Set(roots)] -} - -/** Every installed store this client can drive. */ -function findStores () { - const found = [] - for (const root of storeRoots()) { - let entries = [] - try { - entries = fs.readdirSync(root, { withFileTypes: true }) - } catch { continue } - for (const entry of entries) { - if (!entry.isDirectory()) continue - const home = path.join(root, entry.name) - for (const engine of ENGINES) { - const script = path.join(home, engine.script) - const config = path.join(home, 'config.json') - if (fs.existsSync(script) && fs.existsSync(config)) { - found.push({ engine: engine.id, id: entry.name.replace(engine.homeSuffix, ''), home, script, config }) - } - } - } - } - return found -} - -/** - * The store to drive: the one asked for by home if it is still there, otherwise - * the first one found. The client remembers the choice, so a machine with two - * stores reopens on the one last used rather than on whichever sorts first. - */ -function findStore (preferredHome) { - const stores = findStores() - if (preferredHome) { - const wanted = stores.find((store) => store.home === preferredHome) - if (wanted) return wanted - } - return stores[0] || null -} - -/** - * The store's own name, from the config the installer wrote. Worth reading here - * rather than waiting for `paths`: the switcher lists every store on the machine, - * and starting a Python process per entry to learn its name would be absurd. - */ -function storeName (store) { - try { - const config = JSON.parse(fs.readFileSync(store.config, 'utf8')) - return (config.store && config.store.name) || store.id - } catch { - return store.id - } -} - -/** An installed store as the window needs it. */ -function describe (store) { - return store && { id: store.id, home: store.home, engine: store.engine, name: storeName(store) } -} - -/** Where a store would be installed if there is none yet. */ -function defaultHome (storeId = 'ttg') { - const engine = ENGINES[0] - return path.join(storeRoots()[0], `${storeId}${engine.homeSuffix}`) -} - -// Python 3 is what the CLI needs, and its name differs per platform. `py -3` is -// the Windows launcher, which is often the only one on PATH. -const PYTHON_CANDIDATES = process.platform === 'win32' - ? [['py', ['-3']], ['python', []], ['python3', []]] - : [['python3', []], ['python', []]] - -let cachedPython = null - -function findPython () { - if (cachedPython !== undefined && cachedPython !== null) return cachedPython - for (const [cmd, args] of PYTHON_CANDIDATES) { - try { - const probe = spawnSync(cmd, [...args, '--version'], { encoding: 'utf8', timeout: 10000 }) - const out = `${probe.stdout || ''}${probe.stderr || ''}` - if (probe.status === 0 && /Python 3\./.test(out)) { - cachedPython = { cmd, args, version: out.trim() } - return cachedPython - } - } catch { /* try the next one */ } - } - cachedPython = null - return null -} - -class StoreError extends Error { - constructor (message, code) { - super(message) - this.code = code - } -} - -// The oldest engine that speaks `--json`. An older one is not broken, it simply -// cannot be driven from a window — and it will be met in the wild, because the -// CLI shipped before this client did. -const MIN_ENGINE = [1, 1, 0] - -function parseVersion (text) { - const match = /(\d+)\.(\d+)\.(\d+)/.exec(String(text || '')) - return match ? match.slice(1, 4).map(Number) : null -} - -function atLeast (version, minimum) { - if (!version) return false - for (let i = 0; i < minimum.length; i += 1) { - if ((version[i] || 0) > minimum[i]) return true - if ((version[i] || 0) < minimum[i]) return false - } - return true -} - -/** The installed engine's version string, and whether this client can drive it. */ -function engineVersion (store) { - const python = findPython() - if (!python || !store) return null - try { - const probe = spawnSync(python.cmd, [...python.args, store.script, '--version'], - { encoding: 'utf8', timeout: 15000 }) - const text = `${probe.stdout || ''}${probe.stderr || ''}`.trim() - if (probe.status !== 0 || !text) return null - return { text, version: parseVersion(text), ok: atLeast(parseVersion(text), MIN_ENGINE) } - } catch { - return null - } -} - -/** - * Run one CLI command. - * - * `onLine` gets every stdout line already parsed (the CLI emits one JSON object - * per line), `onLog` every stderr line as text. Resolves with the parsed lines. - */ -function run (store, args, { onLine, onLog, signal } = {}) { - const python = findPython() - if (!python) throw new StoreError('python3 was not found on this machine', 'NO_PYTHON') - if (!store) throw new StoreError('no store is installed yet', 'NO_STORE') - - const argv = [...python.args, store.script, '--config', store.config, '--json', ...args] - return new Promise((resolve, reject) => { - const child = spawn(python.cmd, argv, { - env: { ...process.env, DESKTOP_STORE_HOME: store.home }, - signal - }) - const lines = [] - let stdoutRest = '' - let stderrRest = '' - - const takeStdout = (chunk) => { - stdoutRest += chunk - const parts = stdoutRest.split('\n') - stdoutRest = parts.pop() - for (const part of parts) { - if (!part.trim()) continue - let value - try { - value = JSON.parse(part) - } catch { - // Not ours to interpret — hand it on as a log line rather than crash. - if (onLog) onLog(part) - continue - } - lines.push(value) - if (onLine) onLine(value) - } - } - const takeStderr = (chunk) => { - stderrRest += chunk - const parts = stderrRest.split('\n') - stderrRest = parts.pop() - for (const part of parts) if (part.trim() && onLog) onLog(part) - } - - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', takeStdout) - child.stderr.on('data', takeStderr) - child.on('error', (err) => reject(new StoreError(err.message, 'SPAWN_FAILED'))) - child.on('close', (code) => { - takeStdout('\n') - takeStderr('\n') - if (code === 0) resolve(lines) - else reject(new StoreError(`the store exited with code ${code}`, 'CLI_FAILED')) - }) - }) -} - -async function list (store, hooks) { - const lines = await run(store, ['list'], hooks) - return lines[lines.length - 1] || { games: [], skipped: [] } -} - -async function paths (store, hooks) { - const lines = await run(store, ['paths'], hooks) - return lines[lines.length - 1] || {} -} - -function sync (store, names = [], hooks) { - return run(store, ['sync', ...names], hooks) -} - -function remove (store, name, hooks) { - return run(store, ['remove', name], hooks) -} - -function purge (store, hooks) { - return run(store, ['purge'], hooks) -} - -module.exports = { - ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, describe, engineVersion, - findPython, findStore, findStores, list, parseVersion, paths, purge, remove, run, - storeName, storeRoots, sync -} diff --git a/main.js b/main.js deleted file mode 100644 index 073398c..0000000 --- a/main.js +++ /dev/null @@ -1,357 +0,0 @@ -'use strict' -// Main process: one window, and the IPC that lets it drive the store CLI. -// -// The renderer gets no Node access at all (contextIsolation on, nodeIntegration -// off, sandbox on); everything it can do is in preload.js and handled here. - -const { app, BrowserWindow, ipcMain, shell, dialog } = require('electron') -const fs = require('node:fs') -const path = require('node:path') -const { spawn } = require('node:child_process') - -const store = require('./lib/store') -const bootstrap = require('./lib/bootstrap') -const i18n = require('./lib/i18n') - -let win = null -let current = null // the store we are driving -let busy = false // one CLI call at a time -const prefsFile = () => path.join(app.getPath('userData'), 'prefs.json') - -function loadPrefs () { - try { - return JSON.parse(fs.readFileSync(prefsFile(), 'utf8')) - } catch { - return {} - } -} - -function savePrefs (prefs) { - try { - fs.mkdirSync(path.dirname(prefsFile()), { recursive: true }) - fs.writeFileSync(prefsFile(), JSON.stringify(prefs, null, 2)) - } catch { /* a lost preference is not worth an error dialog */ } -} - -function send (channel, payload) { - if (win && !win.isDestroyed()) win.webContents.send(channel, payload) -} - -const hooks = () => ({ - onLog: (line) => send('store:log', line), - onLine: (event) => send('store:event', event) -}) - -/** One CLI call at a time: the store writes files, and two writers would race. */ -async function guarded (fn) { - if (busy) throw new Error('busy') - busy = true - send('store:busy', true) - try { - return await fn() - } finally { - busy = false - send('store:busy', false) - } -} - -// `--selftest` drives the window once and reports what rendered, so the UI has a -// check that does not need a pair of eyes. It is the only way a renderer error -// would otherwise be noticed: the main process log stays empty. -const SELFTEST = process.argv.includes('--selftest') - -// A test run must never be swallowed by a copy the user already has open: it gets -// its own user-data directory and skips the single-instance lock. Without this the -// second process exits silently with status 0, which reads as a passing test. -if (SELFTEST) { - app.setPath('userData', path.join(app.getPath('temp'), 'warpstore-gui-selftest')) -} - -async function selftest () { - const result = await win.webContents.executeJavaScript(`(() => ({ - cards: document.querySelectorAll('.card').length, - installed: document.querySelectorAll('.card.is-installed').length, - buttons: document.querySelectorAll('.card .actions button').length, - gateVisible: !document.getElementById('gate').hidden, - gateTitle: document.getElementById('gate-title').textContent, - gateChoices: [...document.getElementById('gate-select').options].map((o) => o.text), - gateAction: document.getElementById('gate-action').textContent, - appName: document.getElementById('app-name').textContent, - storeId: document.getElementById('store-id').textContent, - navOpen: !document.body.classList.contains('nav-closed'), - stores: [...document.querySelectorAll('#store-list .store-row')].map((n) => n.textContent), - categories: [...document.querySelectorAll('#cats .cat')].map((n) => n.textContent), - activeCategory: (document.querySelector('#cats .cat.is-active') || {}).textContent || null, - paths: document.getElementById('log-paths').textContent.slice(0, 120), - logLines: document.querySelectorAll('.log-line').length, - locales: [...document.getElementById('locale').options].map((o) => o.value) - }))()`) - console.log(JSON.stringify(result, null, 2)) - - // With two stores on the machine the switcher is the thing most likely to be - // broken without anyone noticing, so the test uses it: click the store that is - // not open and see whether the window follows. Skipped when there is only one, - // which is the normal case — a single store cannot be switched away from. - let switched = null - if (result.stores.length > 1) { - switched = await win.webContents.executeJavaScript(`(async () => { - const other = [...document.querySelectorAll('#store-list .store-row')] - .find((row) => !row.classList.contains('is-active')) - other.click() - await new Promise((done) => setTimeout(done, 8000)) - return { - storeId: document.getElementById('store-id').textContent, - active: (document.querySelector('#store-list .store-row.is-active') || {}).textContent || null, - cards: document.querySelectorAll('.card').length, - categories: document.querySelectorAll('#cats .cat').length - } - })()`) - console.log(`switched: ${JSON.stringify(switched)}`) - } - - // A layout mistake does not show up in the DOM counts above, so on request the - // window photographs itself — the terminal cannot screenshot it from outside. - if (process.env.SELFTEST_SHOT) { - // capturePage hands back the last painted frame, so a window that is behind - // others — or still loading box art — photographs as a half-drawn page. Focus - // it, wait for the images, then let one frame go by. - win.show() - win.focus() - await win.webContents.executeJavaScript(`(async () => { - await Promise.all([...document.images].map((img) => img.complete - ? null - : new Promise((done) => { img.onload = done; img.onerror = done }))) - await new Promise((done) => requestAnimationFrame(() => setTimeout(done, 400))) - return document.images.length - })()`) - const image = await win.webContents.capturePage() - fs.writeFileSync(process.env.SELFTEST_SHOT, image.toPNG()) - console.log(`shot: ${process.env.SELFTEST_SHOT}`) - } - - // Either outcome is a pass: a grid when a store is installed — with the side - // menu populated, which is the part a blank render would silently lose — or the - // setup gate with something to choose from when there is none. - const good = result.locales.length > 1 && ( - (result.cards > 0 && !result.gateVisible && - result.stores.length > 0 && result.categories.length > 0 && result.activeCategory) || - (result.gateVisible && result.gateChoices.length > 0 && result.gateAction)) - const switchGood = switched === null || ( - switched.storeId && switched.storeId !== result.storeId && - switched.cards > 0 && switched.categories > 0) - console.log(good && switchGood ? 'SELFTEST OK' : 'SELFTEST FAILED') - app.exit(good && switchGood ? 0 : 1) -} - -function createWindow () { - win = new BrowserWindow({ - width: 1040, - height: 720, - minWidth: 760, - minHeight: 520, - backgroundColor: '#11151c', - title: 'WarpEngine Store', - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - webSecurity: true - } - }) - - win.loadFile(path.join(__dirname, 'renderer', 'index.html')) - - // A renderer error is invisible from here otherwise. - win.webContents.on('console-message', (_event, level, message) => { - if (level >= 2 || SELFTEST) console.log(`[renderer] ${message}`) - }) - win.webContents.on('render-process-gone', (_event, details) => { - console.log(`[renderer] gone: ${details.reason}`) - if (SELFTEST) app.exit(1) - }) - if (SELFTEST) { - // The first list() has to finish before there is anything to look at. - win.webContents.once('did-finish-load', () => setTimeout(() => { - selftest().catch((err) => { console.log(`SELFTEST ERROR ${err.message}`); app.exit(1) }) - }, 6000)) - } - - // Nothing in this app should ever navigate away or open a second window; a - // link the user clicks goes to their browser instead. - win.webContents.setWindowOpenHandler(({ url }) => { - if (/^https:\/\//.test(url)) shell.openExternal(url) - return { action: 'deny' } - }) - win.webContents.on('will-navigate', (event, url) => { - if (url !== win.webContents.getURL()) { - event.preventDefault() - if (/^https:\/\//.test(url)) shell.openExternal(url) - } - }) -} - -// --- IPC ------------------------------------------------------------------ - -ipcMain.handle('app:state', () => { - const prefs = loadPrefs() - const python = store.findPython() - // Every store on the machine, because the window offers a switch between them — - // and the remembered one wins, so reopening lands where the user left off. - const stores = store.findStores() - current = store.findStore(prefs.store) - // An engine that predates `--json` cannot be driven from a window; the client - // says so and offers to refresh it rather than failing on the first call. - const engine = current ? store.engineVersion(current) : null - return { - locale: i18n.pick(prefs.locale || app.getLocale()), - languages: i18n.languages, - strings: i18n.dict(prefs.locale || app.getLocale()), - nav: prefs.nav !== false, - python: python ? python.version : null, - store: store.describe(current), - stores: stores.map(store.describe), - engine: engine ? { text: engine.text, ok: engine.ok } : null, - minEngine: store.MIN_ENGINE.join('.'), - registryUrl: bootstrap.REGISTRY_URL, - storeRoot: store.storeRoots()[0], - version: app.getVersion() - } -}) - -// The side menu's open/closed state is worth keeping between runs; it is the one -// preference the window sets that is not a language. -ipcMain.handle('app:setNav', (_event, open) => { - const prefs = loadPrefs() - prefs.nav = Boolean(open) - savePrefs(prefs) - return prefs.nav -}) - -/** - * Switch to another installed store. - * - * The choice is remembered, and the engine is checked here rather than in the - * window: two stores on one machine can be at different versions, and the one - * being switched to may be the older one. - */ -ipcMain.handle('store:use', (_event, home) => { - const wanted = store.findStores().find((candidate) => candidate.home === home) - if (!wanted) throw new Error('that store is no longer on this machine') - current = wanted - const prefs = loadPrefs() - prefs.store = wanted.home - savePrefs(prefs) - const engine = store.engineVersion(current) - return { - store: store.describe(current), - engine: engine ? { text: engine.text, ok: engine.ok } : null - } -}) - -ipcMain.handle('app:setLocale', (_event, locale) => { - const prefs = loadPrefs() - prefs.locale = i18n.pick(locale) - savePrefs(prefs) - return { locale: prefs.locale, strings: i18n.dict(prefs.locale) } -}) - -ipcMain.handle('store:list', () => guarded(() => store.list(current, hooks()))) -ipcMain.handle('store:paths', () => guarded(() => store.paths(current, hooks()))) - -ipcMain.handle('store:sync', (_event, names) => - guarded(() => store.sync(current, Array.isArray(names) ? names : [], hooks()))) - -ipcMain.handle('store:remove', (_event, name) => - guarded(() => store.remove(current, String(name), hooks()))) - -// The stores this client can install, from the site's registry rather than from -// anything baked in here. A separate call because it needs the network: the first -// window paints without waiting for it. -ipcMain.handle('store:registry', async () => { - try { - return { stores: await bootstrap.registry() } - } catch (err) { - return { stores: [], error: err.message, url: bootstrap.REGISTRY_URL } - } -}) - -ipcMain.handle('store:bootstrap', (_event, chosen) => guarded(async () => { - if (!chosen) throw new Error('no store was chosen') - const home = bootstrap.homeFor(chosen, store.storeRoots()[0]) - const result = await bootstrap.install(home, chosen, { onLog: (line) => send('store:log', line) }) - current = { engine: 'desktop', ...result } - const prefs = loadPrefs() - prefs.store = current.home - savePrefs(prefs) - return { ...store.describe(current), name: chosen.name } -})) - -/** - * Launch what was installed. - * - * A hosted title is a URL, so it goes to the browser. A native one is whatever - * the store recorded: on macOS the app bundle through `open`, elsewhere the - * executable from its own directory — the same working directory the menu entry - * uses, because games load their assets relative to it. - */ -ipcMain.handle('store:launch', async (_event, game) => { - if (!game) return false - if (game.mode === 'web' && game.url) { - await shell.openExternal(game.url) - return true - } - const target = game.menu_entry || game.exe - if (!target || !fs.existsSync(target)) return false - if (process.platform === 'darwin' && target.endsWith('.app')) { - spawn('open', [target], { detached: true, stdio: 'ignore' }).unref() - return true - } - if (process.platform === 'win32' || target.endsWith('.desktop')) { - const error = await shell.openPath(target) - if (!error) return true - } - const exe = game.exe || target - spawn(exe, [], { cwd: path.dirname(exe), detached: true, stdio: 'ignore' }).unref() - return true -}) - -ipcMain.handle('app:openFolder', async (_event, dir) => { - if (!dir) return false - const error = await shell.openPath(dir) - return !error -}) - -ipcMain.handle('app:openExternal', async (_event, url) => { - if (!/^https:\/\//.test(String(url))) return false - await shell.openExternal(String(url)) - return true -}) - -// --- lifecycle ------------------------------------------------------------ - -if (!SELFTEST && !app.requestSingleInstanceLock()) { - app.quit() -} else { - app.on('second-instance', () => { - if (win) { - if (win.isMinimized()) win.restore() - win.focus() - } - }) - - app.whenReady().then(() => { - createWindow() - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() - }) - }) - - app.on('window-all-closed', () => { - if (process.platform !== 'darwin') app.quit() - }) - - process.on('unhandledRejection', (reason) => { - dialog.showErrorBox('WarpEngine Store', String(reason && reason.message ? reason.message : reason)) - }) -} diff --git a/package-lock.json b/package-lock.json index 830a74c..482aad8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,21 @@ { "name": "warp-engine-desktop-gui", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "warp-engine-desktop-gui", - "version": "1.0.0", + "version": "1.2.0", "license": "MIT", "devDependencies": { + "@types/node": "^26.2.0", "electron": "^43.4.0", - "electron-builder": "^26.15.3" + "electron-builder": "^26.15.3", + "esbuild": "^0.28.2", + "eslint": "^10.8.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0" }, "engines": { "node": ">=22" @@ -320,6 +325,621 @@ "node": ">=14.14" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -502,6 +1122,20 @@ "@types/ms": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -519,6 +1153,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -537,13 +1178,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/responselike": { @@ -556,6 +1197,236 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.14", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz", @@ -576,6 +1447,29 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1254,6 +2148,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -1581,6 +2482,23 @@ "node": ">= 4.0.0" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1675,6 +2593,48 @@ "license": "MIT", "optional": true }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1691,7 +2651,6 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, @@ -1699,6 +2658,185 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -1713,6 +2851,20 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", @@ -1748,6 +2900,19 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -1788,6 +2953,44 @@ "node": ">=10" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -1924,6 +3127,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/glob/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2166,6 +3382,26 @@ "node": ">= 14" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2185,6 +3421,16 @@ "dev": true, "license": "ISC" }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2195,6 +3441,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -2290,6 +3549,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -2341,6 +3607,36 @@ "dev": true, "license": "MIT" }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2511,6 +3807,13 @@ "dev": true, "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-abi": { "version": "4.33.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", @@ -2662,6 +3965,24 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -2688,6 +4009,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2819,6 +4166,16 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -2883,6 +4240,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -3378,6 +4745,19 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3385,6 +4765,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -3399,6 +4792,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", @@ -3411,9 +4842,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, @@ -3456,6 +4887,16 @@ "node": ">=14.14" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -3500,6 +4941,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index a8cde22..f1db9d5 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,43 @@ { "name": "warp-engine-desktop-gui", "productName": "WarpEngine Store", - "version": "1.2.0", + "version": "1.3.0", "description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.", "license": "MIT", "author": "Teletype Games ", "homepage": "https://git.teletypegames.org/stores/warp-engine-desktop-gui", - "main": "main.js", + "main": "build/main/main.js", "engines": { "node": ">=22" }, "scripts": { - "start": "electron .", - "smoke": "node scripts/smoke.js", - "dist": "electron-builder", - "dist:mac": "electron-builder --mac", - "dist:win": "electron-builder --win", - "dist:linux": "electron-builder --linux", - "uitest": "electron . --selftest" + "build": "tsc -p tsconfig.build.json && node scripts/build-assets.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "start": "npm run build && electron .", + "smoke": "npm run build && node build/scripts/SmokeTest.js", + "uitest": "npm run build && electron . --selftest", + "dist": "npm run build && electron-builder", + "dist:mac": "npm run build && electron-builder --mac", + "dist:win": "npm run build && electron-builder --win", + "dist:linux": "npm run build && electron-builder --linux" }, "devDependencies": { + "@types/node": "^26.2.0", "electron": "^43.4.0", - "electron-builder": "^26.15.3" + "electron-builder": "^26.15.3", + "esbuild": "^0.28.2", + "eslint": "^10.8.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0" }, "build": { "appId": "org.teletypegames.warpstore.gui", "productName": "WarpEngine Store", "files": [ - "main.js", - "preload.js", - "lib/**/*", - "renderer/**/*" + "build/**/*", + "package.json" ], "mac": { "category": "public.app-category.games", @@ -55,6 +62,7 @@ "afterPack": "scripts/after-pack.js" }, "allowScripts": { - "electron@43.4.0": true + "electron@43.4.0": true, + "esbuild@0.28.2": true } } diff --git a/preload.js b/preload.js deleted file mode 100644 index 78ee99e..0000000 --- a/preload.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict' -// The whole surface the renderer gets. No Node, no fs, no child_process — just -// these calls and two event streams. - -const { contextBridge, ipcRenderer } = require('electron') - -contextBridge.exposeInMainWorld('storeApi', { - state: () => ipcRenderer.invoke('app:state'), - setLocale: (locale) => ipcRenderer.invoke('app:setLocale', locale), - setNav: (open) => ipcRenderer.invoke('app:setNav', open), - - list: () => ipcRenderer.invoke('store:list'), - paths: () => ipcRenderer.invoke('store:paths'), - sync: (names) => ipcRenderer.invoke('store:sync', names), - remove: (name) => ipcRenderer.invoke('store:remove', name), - use: (home) => ipcRenderer.invoke('store:use', home), - registry: () => ipcRenderer.invoke('store:registry'), - bootstrap: (store) => ipcRenderer.invoke('store:bootstrap', store), - launch: (game) => ipcRenderer.invoke('store:launch', game), - - openFolder: (dir) => ipcRenderer.invoke('app:openFolder', dir), - openExternal: (url) => ipcRenderer.invoke('app:openExternal', url), - - // Streams from the running CLI: `log` is a line a person can read, `event` is - // one of the store's JSON progress events. - onLog: (fn) => ipcRenderer.on('store:log', (_e, line) => fn(line)), - onEvent: (fn) => ipcRenderer.on('store:event', (_e, event) => fn(event)), - onBusy: (fn) => ipcRenderer.on('store:busy', (_e, value) => fn(value)) -}) diff --git a/renderer/app.js b/renderer/app.js deleted file mode 100644 index 5434629..0000000 --- a/renderer/app.js +++ /dev/null @@ -1,555 +0,0 @@ -'use strict' -// The whole renderer. No framework and no build step: a side menu on the left -// decides what is shown, a grid of cards on the right shows it, and every action -// is one call over the bridge in preload.js. - -const api = window.storeApi -const el = (id) => document.getElementById(id) - -let T = {} // the active string table -let state = null // what the main process knows: stores, python, engine -let games = [] -let paths = null -let busy = false -let plan = null // { total, done } while a sync is running - -// What the grid is narrowed down to. One category at a time on purpose: a matrix -// of filters would need explaining, and a catalog of this size does not earn it. -let filter = { kind: 'group', value: 'all' } - -// --- helpers -------------------------------------------------------------- - -function text (node, value) { - node.textContent = value == null ? '' : String(value) -} - -function imageUrl (game) { - if (!game.image_url) return null - if (/^https?:\/\//.test(game.image_url)) return game.image_url - const base = (paths && paths.store && paths.store.base_url) || '' - return base ? `${base}${game.image_url}` : null -} - -function logLine (line) { - const box = el('log-lines') - const row = document.createElement('div') - row.className = 'log-line' - text(row, line) - box.appendChild(row) - while (box.childElementCount > 400) box.removeChild(box.firstChild) - box.scrollTop = box.scrollHeight -} - -// While the CLI runs, anything that would start a second call is disabled. The -// menu toggle, the log drawer and the category filters are not among them: they -// only change what is on screen. -function setBusy (value) { - busy = value - for (const node of document.querySelectorAll('button')) { - if (node.id === 'log-toggle' || node.id === 'nav-toggle') continue - if (node.classList.contains('cat')) continue - node.disabled = value - } - const progress = el('progress') - if (!value) { - progress.hidden = true - plan = null - } -} - -function showProgress (label) { - const progress = el('progress') - progress.hidden = false - text(progress, label) -} - -// --- the side menu -------------------------------------------------------- - -function setNav (open) { - document.body.classList.toggle('nav-closed', !open) - el('nav-toggle').setAttribute('aria-expanded', String(open)) -} - -function renderStores () { - const box = el('store-list') - const stores = (state && state.stores) || [] - const active = state && state.store ? state.store.home : null - // Two stores can carry the same id in different roots — the same catalog - // installed twice. Then the id says nothing and the folder is what tells them - // apart, so that is what the row shows. - const ambiguous = new Set(stores - .filter((store, index) => stores.findIndex((other) => other.id === store.id) !== index) - .map((store) => store.id)) - box.replaceChildren(...stores.map((store) => { - const row = document.createElement('button') - row.className = 'store-row' - if (store.home === active) row.classList.add('is-active') - const name = document.createElement('span') - name.className = 'store-row-name' - text(name, store.name) - row.appendChild(name) - const id = document.createElement('span') - id.className = 'store-row-id' - text(id, ambiguous.has(store.id) ? store.home : store.id) - row.appendChild(id) - row.title = store.home - row.addEventListener('click', () => { - if (store.home !== active) switchStore(store.home) - }) - return row - })) - el('add-store').hidden = false -} - -/** - * The categories, built from what the catalog actually contains. - * - * There is no genre in a WarpEngine catalog, so the useful axes are the state of - * a title on this machine, the platform it was built with, and whether it runs - * here or in a browser. Empty axes are left out rather than shown as zeroes. - */ -function categories () { - const count = (fn) => games.filter(fn).length - const sections = [{ - group: null, - items: [ - { kind: 'group', value: 'all', label: T.catAll, count: games.length }, - { kind: 'group', value: 'installed', label: T.catInstalled, count: count((g) => g.installed) }, - { kind: 'group', value: 'updates', label: T.catUpdates, count: count((g) => g.update_available) }, - { kind: 'group', value: 'available', label: T.catAvailable, count: count((g) => !g.installed) } - ].filter((item) => item.value === 'all' || item.count > 0) - }] - - const platforms = [...new Set(games.map((g) => g.platform).filter(Boolean))].sort() - if (platforms.length > 1) { - sections.push({ - group: T.catPlatform, - items: platforms.map((platform) => ({ - kind: 'platform', value: platform, label: platform, count: count((g) => g.platform === platform) - })) - }) - } - - const modes = [...new Set(games.map((g) => g.mode).filter(Boolean))] - if (modes.length > 1) { - sections.push({ - group: T.catMode, - items: modes.map((mode) => ({ - kind: 'mode', value: mode, label: mode === 'web' ? T.hosted : T.native, count: count((g) => g.mode === mode) - })) - }) - } - return sections -} - -function matches (game) { - if (filter.kind === 'platform') return game.platform === filter.value - if (filter.kind === 'mode') return game.mode === filter.value - if (filter.value === 'installed') return Boolean(game.installed) - if (filter.value === 'updates') return Boolean(game.update_available) - if (filter.value === 'available') return !game.installed - return true -} - -function renderCats () { - const box = el('cats') - const sections = categories() - - // A category can vanish under us — the last title of a platform is removed, or - // an update is applied — and a filter matching nothing would look like an empty - // catalog. Falling back to everything is the honest answer. - const known = sections.flatMap((section) => section.items) - .some((item) => item.kind === filter.kind && item.value === filter.value) - if (!known) filter = { kind: 'group', value: 'all' } - - const nodes = [] - for (const section of sections) { - if (section.group) { - const head = document.createElement('div') - head.className = 'cat-group' - text(head, section.group) - nodes.push(head) - } - for (const item of section.items) { - const button = document.createElement('button') - button.className = 'cat' - if (item.kind === filter.kind && item.value === filter.value) button.classList.add('is-active') - const label = document.createElement('span') - label.className = 'cat-label' - text(label, item.label) - button.appendChild(label) - const count = document.createElement('span') - count.className = 'cat-count' - text(count, item.count) - button.appendChild(count) - button.addEventListener('click', () => { - filter = { kind: item.kind, value: item.value } - renderCats() - renderGrid() - }) - nodes.push(button) - } - } - box.replaceChildren(...nodes) -} - -// --- the card grid -------------------------------------------------------- - -function card (game) { - const node = document.createElement('article') - node.className = 'card' - if (game.installed) node.classList.add('is-installed') - - const art = document.createElement('div') - art.className = 'art' - const src = imageUrl(game) - if (src) { - const img = document.createElement('img') - img.src = src - img.alt = '' - img.loading = 'lazy' - art.appendChild(img) - } else { - // No box art in the catalog: the first letter, on the same band an image - // would fill, so a row of cards stays aligned either way. - const glyph = document.createElement('span') - glyph.className = 'art-glyph' - text(glyph, game.title.slice(0, 1).toUpperCase()) - art.appendChild(glyph) - } - node.appendChild(art) - - const body = document.createElement('div') - body.className = 'body' - - const title = document.createElement('h2') - text(title, game.title) - body.appendChild(title) - - const meta = document.createElement('div') - meta.className = 'meta' - const mode = document.createElement('span') - mode.className = `badge badge-${game.mode}` - text(mode, game.mode === 'web' ? T.hosted : T.native) - mode.title = game.mode === 'web' ? T.hostedHint : T.nativeHint - meta.appendChild(mode) - const platform = document.createElement('span') - platform.className = 'badge badge-plain' - text(platform, game.platform) - meta.appendChild(platform) - const version = document.createElement('span') - version.className = 'version' - text(version, game.installed && game.installed_version - ? `${game.installed_version} · ${T.installed}` - : game.version) - meta.appendChild(version) - body.appendChild(meta) - - if (game.desc) { - const desc = document.createElement('p') - desc.className = 'desc' - text(desc, game.desc) - body.appendChild(desc) - } - - const actions = document.createElement('div') - actions.className = 'actions' - - if (game.installed && !game.update_available) { - const play = document.createElement('button') - play.className = 'btn btn-primary' - text(play, game.mode === 'web' ? T.open : T.play) - play.addEventListener('click', () => api.launch(game)) - actions.appendChild(play) - } else { - const install = document.createElement('button') - install.className = 'btn btn-primary' - text(install, game.update_available ? T.update : T.install) - install.addEventListener('click', () => runSync([game.name])) - actions.appendChild(install) - } - - if (game.installed) { - const remove = document.createElement('button') - remove.className = 'btn btn-ghost' - text(remove, T.remove) - remove.addEventListener('click', () => runRemove(game.name)) - actions.appendChild(remove) - } - - body.appendChild(actions) - node.appendChild(body) - return node -} - -function renderGrid () { - const shown = games.filter(matches) - const grid = el('grid') - grid.replaceChildren(...shown.map(card)) - grid.hidden = shown.length === 0 - grid.scrollTop = 0 - const empty = el('empty') - empty.hidden = shown.length !== 0 - text(empty, games.length === 0 ? T.noGames : T.noMatch) -} - -function renderPaths () { - const box = el('log-paths') - box.replaceChildren() - if (!paths) return - const line = document.createElement('div') - line.className = 'paths-line' - text(line, `${T.paths}: ${paths.store_folder} · ${paths.menu_group}`) - box.appendChild(line) - - for (const [label, dir] of [[T.openStoreFolder, paths.store_folder], - [T.openMenuFolder, paths.menu_group]]) { - const button = document.createElement('button') - button.className = 'btn btn-tiny' - text(button, label) - button.addEventListener('click', () => api.openFolder(dir)) - box.appendChild(button) - } -} - -// --- actions -------------------------------------------------------------- - -async function refresh () { - try { - const result = await api.list() - games = result.games || [] - paths = result.paths || paths - renderCats() - renderGrid() - renderPaths() - for (const reason of result.skipped || []) logLine(`skipped ${reason}`) - } catch (err) { - logLine(String(err && err.message ? err.message : err)) - } -} - -async function runSync (names) { - try { - await api.sync(names || []) - } catch (err) { - logLine(String(err && err.message ? err.message : err)) - } - await refresh() -} - -async function runRemove (name) { - try { - await api.remove(name) - } catch (err) { - logLine(String(err && err.message ? err.message : err)) - } - await refresh() -} - -/** Open another store that is already on this machine. */ -async function switchStore (home) { - try { - const next = await api.use(home) - state.store = next.store - state.engine = next.engine - text(el('store-id'), next.store.id) - renderStores() - games = [] - paths = null - filter = { kind: 'group', value: 'all' } - if (next.engine && !next.engine.ok) { - renderCats() - showOldEngineGate() - return - } - hideGate() - await refresh() - } catch (err) { - logLine(`${T.switchFailed}: ${err && err.message ? err.message : err}`) - } -} - -/** Pick up a store that appeared since the window opened. */ -async function reloadState () { - state = await api.state() - text(el('store-id'), state.store ? state.store.id : '') - renderStores() -} - -// --- gate: no python, no store yet, or an engine too old ------------------ - -function showGate (title, body, action, link, choices) { - el('grid').hidden = true - el('empty').hidden = true - const gate = el('gate') - gate.hidden = false - text(el('gate-title'), title) - text(el('gate-body'), body) - - // Only shown when the registry offers more than one store; with a single one - // there is nothing to decide. - const choice = el('gate-choice') - const select = el('gate-select') - choice.hidden = !choices || choices.length < 2 - if (!choice.hidden) { - text(el('gate-choice-label'), T.setupChoose) - select.replaceChildren(...choices.map((store, index) => { - const option = document.createElement('option') - option.value = String(index) - option.textContent = store.name - return option - })) - } - - const button = el('gate-action') - button.hidden = !action - if (action) { - text(button, action.label) - button.onclick = () => action.onClick(choices ? choices[Number(select.value) || 0] : undefined) - } - const anchor = el('gate-link') - anchor.hidden = !link - if (link) { - text(anchor, link.label) - anchor.onclick = (event) => { event.preventDefault(); api.openExternal(link.url) } - } -} - -function hideGate () { - el('gate').hidden = true -} - -function showOldEngineGate () { - showGate(T.oldEngineTitle, - `${T.oldEngineBody}\n\n${state.engine.text} → ${state.minEngine}`, - { label: T.oldEngineAction, onClick: offerStores }) -} - -async function setUpStore (chosen) { - showProgress(T.setupWorking) - try { - await api.bootstrap(chosen) - await reloadState() - hideGate() - await refresh() - } catch (err) { - logLine(String(err && err.message ? err.message : err)) - } -} - -// Which stores exist is the site's answer, not this client's: the registry is -// asked for it, and its records carry the catalog and the config repository. -async function offerStores () { - const result = await api.registry() - if (result.error) { - showGate(`${T.registryFailed}`, `${result.url}\n\n${result.error}`, - { label: T.registryRetry, onClick: offerStores }) - return - } - if (!result.stores.length) { - showGate(T.setupTitle, `${T.registryEmpty}\n\n${result.url}`, null) - return - } - showGate(T.setupTitle, - `${T.setupBody}\n\n${state.storeRoot}`, - { label: T.setupAction, onClick: async (chosen) => { await setUpStore(chosen); await runSync([]) } }, - null, - result.stores) -} - -// --- boot ----------------------------------------------------------------- - -function applyStrings (strings) { - T = strings - text(el('app-name'), T.appName) - text(el('sync-all'), T.syncAll) - text(el('refresh'), T.refresh) - text(el('log-toggle'), T.log) - text(el('head-stores'), T.stores) - text(el('head-actions'), T.actions) - text(el('head-cats'), T.categories) - text(el('head-lang'), T.language) - text(el('add-store'), T.addStore) - el('nav-toggle').title = T.menu - el('nav-toggle').setAttribute('aria-label', T.menu) - renderPaths() - if (state) renderStores() - if (games.length) { - renderCats() - renderGrid() - } -} - -async function boot () { - state = await api.state() - applyStrings(state.strings) - setNav(state.nav !== false) - renderStores() - - const select = el('locale') - select.replaceChildren(...state.languages.map((code) => { - const option = document.createElement('option') - option.value = code - option.textContent = code.toUpperCase() - if (code === state.locale) option.selected = true - return option - })) - select.addEventListener('change', async () => { - const next = await api.setLocale(select.value) - applyStrings(next.strings) - }) - - if (!state.python) { - showGate(T.noPythonTitle, T.noPythonBody, null, - { label: T.pythonLink, url: 'https://www.python.org/downloads/' }) - return - } - - if (!state.store) { - await offerStores() - return - } - - if (state.engine && !state.engine.ok) { - showOldEngineGate() - return - } - - text(el('store-id'), state.store.id) - hideGate() - await refresh() -} - -el('sync-all').addEventListener('click', () => runSync([])) -el('refresh').addEventListener('click', () => refresh()) -el('add-store').addEventListener('click', () => offerStores()) -el('nav-toggle').addEventListener('click', () => { - const open = document.body.classList.contains('nav-closed') - setNav(open) - api.setNav(open) -}) -el('log-toggle').addEventListener('click', () => { - const box = el('log-lines') - box.hidden = !box.hidden - el('log-toggle').setAttribute('aria-expanded', String(!box.hidden)) -}) - -api.onLog(logLine) -api.onBusy(setBusy) -api.onEvent((event) => { - if (event.event === 'plan') { - plan = { total: event.count, done: 0 } - showProgress(`0 ${T.of} ${event.count}`) - } else if (event.event === 'begin' && plan) { - showProgress(`${plan.done + 1} ${T.of} ${plan.total} · ${event.title}`) - } else if (event.event === 'installed' && plan) { - plan.done += 1 - logLine(`${event.title} — ${event.changed ? T.installed : T.upToDate}`) - } else if (event.event === 'failed') { - logLine(`${event.name}: ${T.failed} — ${event.error}`) - } else if (event.event === 'removed') { - logLine(`${event.name} — ${T.removed}`) - } -}) - -boot() diff --git a/scripts/build-assets.mjs b/scripts/build-assets.mjs new file mode 100644 index 0000000..8f51da5 --- /dev/null +++ b/scripts/build-assets.mjs @@ -0,0 +1,54 @@ +// The two bundles and the two static files. +// +// tsc compiles the main process, where CommonJS and `require` are fine. The preload +// and the renderer cannot work that way: a sandboxed preload may not require its own +// modules, and a module script over file:// is blocked by the page's own origin +// rules. So both are bundled into one file each — the layering stays in src/, the +// window gets a single script. +import { build } from 'esbuild' +import { copyFile, mkdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const outDir = join(root, 'build') + +const bundles = [ + { + label: 'preload', + entryPoints: [join(root, 'src/preload/preload.ts')], + outfile: join(outDir, 'preload/preload.js'), + platform: 'node', + format: 'cjs', + // Provided by Electron at runtime; bundling it would break the sandbox. + external: ['electron'] + }, + { + label: 'renderer', + entryPoints: [join(root, 'src/renderer/main.ts')], + outfile: join(outDir, 'renderer/app.js'), + platform: 'browser', + format: 'iife', + external: [] + } +] + +for (const bundle of bundles) { + await build({ + entryPoints: bundle.entryPoints, + outfile: bundle.outfile, + bundle: true, + platform: bundle.platform, + format: bundle.format, + external: bundle.external, + target: 'es2023', + logLevel: 'warning' + }) + console.log(`bundled ${bundle.label} -> ${bundle.outfile.replace(`${root}/`, '')}`) +} + +await mkdir(join(outDir, 'renderer'), { recursive: true }) +for (const asset of ['index.html', 'style.css']) { + await copyFile(join(root, 'src/renderer', asset), join(outDir, 'renderer', asset)) + console.log(`copied ${asset}`) +} diff --git a/scripts/smoke.js b/scripts/smoke.js deleted file mode 100644 index 007c970..0000000 --- a/scripts/smoke.js +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env node -'use strict' -// Drives the bridge without Electron: no window, no packaging, just the part -// that talks to the store. This is where an integration mistake shows up first, -// so it is the check to run after touching lib/store.js or the CLI. -// -// npm run smoke the store installed on this machine -// SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store - -const path = require('node:path') -const fs = require('node:fs') -const store = require('../lib/store') -const bootstrap = require('../lib/bootstrap') -const i18n = require('../lib/i18n') - -function ok (label, value) { - console.log(` ok ${label}${value === undefined ? '' : `: ${value}`}`) -} -function bad (label, value) { - console.log(` FAIL ${label}${value === undefined ? '' : `: ${value}`}`) - process.exitCode = 1 -} - -async function main () { - console.log('warp-engine-desktop-gui smoke test') - - const python = store.findPython() - if (python) ok('python', python.version) - else return bad('python', 'not found — the store cannot run') - - for (const lang of i18n.languages) { - const dict = i18n.dict(lang) - const missing = Object.keys(i18n.STRINGS[i18n.FALLBACK]).filter((k) => !dict[k]) - if (missing.length) bad(`strings:${lang}`, `missing ${missing.join(', ')}`) - else ok(`strings:${lang}`, `${Object.keys(dict).length} keys`) - } - - // The registry is what decides which stores exist, so it is checked before - // anything that depends on one being installed. - try { - const stores = await bootstrap.registry() - if (!stores.length) bad('registry', `${bootstrap.REGISTRY_URL} returned no stores`) - else { - ok('registry', `${stores.length} store(s) from ${bootstrap.REGISTRY_URL}`) - for (const store of stores) { - ok(` ${store.name}`, `${store.catalogUrl} · ${bootstrap.storeId(store)}`) - const url = bootstrap.configUrl(store.storeRepositoryUrl) - try { - const config = JSON.parse(await bootstrap.fetchText(url)) - ok(' config.json', `${Object.keys(config).length} sections`) - } catch (err) { - // Not fatal: the engine merges onto its defaults, so a store without a - // config file still installs. - ok(' config.json', `absent (${err.statusCode || err.message}) — defaults would be used`) - } - } - } - } catch (err) { - bad('registry', `${bootstrap.REGISTRY_URL}: ${err.message}`) - } - - let target = null - if (process.env.SMOKE_HOME) { - const home = path.resolve(process.env.SMOKE_HOME) - target = { - engine: 'desktop', - id: path.basename(home).replace(/-desktop$/, ''), - home, - script: path.join(home, 'desktop_store.py'), - config: path.join(home, 'config.json') - } - for (const file of [target.script, target.config]) { - if (!fs.existsSync(file)) return bad('SMOKE_HOME', `${file} is missing`) - } - ok('store (SMOKE_HOME)', target.home) - } else { - const stores = store.findStores() - if (!stores.length) { - console.log(' skip no store installed — run the app once, or set SMOKE_HOME') - console.log(` it would be installed in ${store.defaultHome()}`) - return - } - target = stores[0] - ok('store found', `${target.id} in ${target.home}`) - } - - const logs = [] - const paths = await store.paths(target, { onLog: (l) => logs.push(l) }) - if (paths.os && paths.store_folder) ok('paths', `${paths.os} → ${paths.store_folder}`) - else bad('paths', JSON.stringify(paths)) - - const listing = await store.list(target, { onLog: (l) => logs.push(l) }) - const games = listing.games || [] - if (!games.length) return bad('list', 'no games came back') - - const modes = games.reduce((acc, g) => { - acc[g.mode] = (acc[g.mode] || 0) + 1 - return acc - }, {}) - ok('list', `${games.length} titles (${Object.entries(modes).map(([m, n]) => `${m}:${n}`).join(', ')})`) - - const required = ['name', 'title', 'platform', 'version', 'mode', 'kind', 'installed', 'update_available'] - const broken = games.filter((g) => required.some((k) => g[k] === undefined)) - if (broken.length) bad('game shape', `${broken.length} entries miss a field`) - else ok('game shape', required.join(', ')) - - const hosted = games.filter((g) => g.mode === 'web') - if (hosted.length && !hosted.every((g) => /^https?:\/\//.test(g.url || ''))) { - bad('hosted urls', 'a web title has no usable url') - } else if (hosted.length) { - ok('hosted urls', hosted[0].url) - } - - const installed = games.filter((g) => g.installed) - ok('installed', `${installed.length} of ${games.length}`) - if (installed.length) { - const withTarget = installed.filter((g) => g.menu_entry || g.exe || g.url) - if (withTarget.length !== installed.length) bad('launch targets', 'an installed title has nothing to launch') - else ok('launch targets', 'every installed title has one') - } - - if (logs.length) ok('stderr log', `${logs.length} lines (kept off stdout)`) -} - -main().catch((err) => { - console.log(` FAIL ${err && err.code ? err.code : 'error'}: ${err && err.message ? err.message : err}`) - process.exitCode = 1 -}) diff --git a/src/application/mappers/EngineVersionDtoMapper.ts b/src/application/mappers/EngineVersionDtoMapper.ts new file mode 100644 index 0000000..5a675c5 --- /dev/null +++ b/src/application/mappers/EngineVersionDtoMapper.ts @@ -0,0 +1,8 @@ +import type { EngineVersion } from '../../domain/models/EngineVersion' +import type { EngineVersionDto } from '../../shared/contracts/dto/EngineVersionDto' + +export class EngineVersionDtoMapper { + public toDto (version: EngineVersion): EngineVersionDto { + return { text: version.text, supported: version.supported } + } +} diff --git a/src/application/mappers/GameDtoMapper.ts b/src/application/mappers/GameDtoMapper.ts new file mode 100644 index 0000000..8ab4b29 --- /dev/null +++ b/src/application/mappers/GameDtoMapper.ts @@ -0,0 +1,48 @@ +import type { Game } from '../../domain/models/Game' +import type { GameDto } from '../../shared/contracts/dto/GameDto' + +const ABSOLUTE_URL = /^https?:\/\// + +/** + * A title as the window may see it. + * + * Two decisions live here rather than in the renderer: the box art is resolved + * against the catalog's base URL, and whether a title can be launched is answered + * here — so the window never receives a filesystem path it could be talked into + * opening. + */ +export class GameDtoMapper { + public toDto (game: Game, catalogBaseUrl: string): GameDto { + return { + name: game.name, + title: game.title, + platform: game.platform, + version: game.version, + mode: game.mode, + kind: game.kind, + description: game.description, + author: game.author, + imageUrl: this.resolveImageUrl(game, catalogBaseUrl), + installed: game.installed, + updateAvailable: game.updateAvailable, + installedVersion: game.installedVersion, + launchable: this.isLaunchable(game) + } + } + + public toDtoList (games: readonly Game[], catalogBaseUrl: string): readonly GameDto[] { + return games.map((game: Game): GameDto => this.toDto(game, catalogBaseUrl)) + } + + private resolveImageUrl (game: Game, catalogBaseUrl: string): string | null { + if (game.imagePath === null) return null + if (ABSOLUTE_URL.test(game.imagePath)) return game.imagePath + return catalogBaseUrl.length > 0 ? `${catalogBaseUrl}${game.imagePath}` : null + } + + private isLaunchable (game: Game): boolean { + if (!game.installed) return false + if (game.mode === 'web') return game.hostedUrl !== null + return game.menuEntryPath !== null || game.executablePath !== null + } +} diff --git a/src/application/mappers/InstalledStoreDtoMapper.ts b/src/application/mappers/InstalledStoreDtoMapper.ts new file mode 100644 index 0000000..a3db582 --- /dev/null +++ b/src/application/mappers/InstalledStoreDtoMapper.ts @@ -0,0 +1,12 @@ +import type { InstalledStore } from '../../domain/models/InstalledStore' +import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto' + +export class InstalledStoreDtoMapper { + public toDto (store: InstalledStore): InstalledStoreDto { + return { id: store.id, name: store.name, home: store.home, engine: store.engine } + } + + public toDtoList (stores: readonly InstalledStore[]): readonly InstalledStoreDto[] { + return stores.map((store: InstalledStore): InstalledStoreDto => this.toDto(store)) + } +} diff --git a/src/application/mappers/RegistryStoreDtoMapper.ts b/src/application/mappers/RegistryStoreDtoMapper.ts new file mode 100644 index 0000000..0485793 --- /dev/null +++ b/src/application/mappers/RegistryStoreDtoMapper.ts @@ -0,0 +1,27 @@ +import type { RegistryStore } from '../../domain/models/RegistryStore' +import { deriveStoreId } from '../../domain/models/StoreIdentity' +import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto' + +export class RegistryStoreDtoMapper { + public toDto (store: RegistryStore): RegistryStoreDto { + return { + name: store.name, + catalogUrl: store.catalogUrl, + storeRepositoryUrl: store.storeRepositoryUrl, + storeId: deriveStoreId(store) + } + } + + public toDtoList (stores: readonly RegistryStore[]): readonly RegistryStoreDto[] { + return stores.map((store: RegistryStore): RegistryStoreDto => this.toDto(store)) + } + + /** The window hands a record straight back when asking for an install. */ + public toModel (dto: RegistryStoreDto): RegistryStore { + return { + name: dto.name, + catalogUrl: dto.catalogUrl, + storeRepositoryUrl: dto.storeRepositoryUrl + } + } +} diff --git a/src/application/mappers/StorePathsDtoMapper.ts b/src/application/mappers/StorePathsDtoMapper.ts new file mode 100644 index 0000000..04bb8ad --- /dev/null +++ b/src/application/mappers/StorePathsDtoMapper.ts @@ -0,0 +1,16 @@ +import type { StorePaths } from '../../domain/models/StorePaths' +import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto' + +export class StorePathsDtoMapper { + public toDto (paths: StorePaths): StorePathsDto { + return { + operatingSystem: paths.operatingSystem, + architecture: paths.architecture, + storeFolder: paths.storeFolder, + menuGroup: paths.menuGroup, + catalogBaseUrl: paths.catalogBaseUrl, + storeName: paths.storeName, + storeId: paths.storeId + } + } +} diff --git a/src/application/services/ApplicationStateService.ts b/src/application/services/ApplicationStateService.ts new file mode 100644 index 0000000..8b505b3 --- /dev/null +++ b/src/application/services/ApplicationStateService.ts @@ -0,0 +1,54 @@ +import { MINIMUM_ENGINE_VERSION, formatVersion } from '../../domain/models/EngineVersion' +import type { InstalledStore } from '../../domain/models/InstalledStore' +import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment' +import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator' +import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto' +import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog' +import { EngineVersionDtoMapper } from '../mappers/EngineVersionDtoMapper' +import { InstalledStoreDtoMapper } from '../mappers/InstalledStoreDtoMapper' +import type { PreferencesService } from './PreferencesService' +import type { StoreProvisioningService } from './StoreProvisioningService' +import type { StoreSelectionService } from './StoreSelectionService' + +/** + * Everything the window needs before it can paint anything, in one answer. + * + * One call rather than six, because the first frame should not be a sequence of + * round trips — and because the decisions the window makes from it (no Python, no + * store, an engine too old) all depend on each other. + */ +export class ApplicationStateService { + public constructor ( + private readonly preferences: PreferencesService, + private readonly selection: StoreSelectionService, + private readonly provisioning: StoreProvisioningService, + private readonly pythonLocator: PythonRuntimeLocator, + private readonly environment: ApplicationEnvironment, + private readonly translations: TranslationCatalog = new TranslationCatalog(), + private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper(), + private readonly engineMapper: EngineVersionDtoMapper = new EngineVersionDtoMapper() + ) {} + + public readState (): AppStateDto { + const locale = this.preferences.readLocale() + const stores = this.selection.listStores() + const current: InstalledStore | null = this.selection.findCurrentStore() + const engine = current === null ? null : this.selection.findEngineVersion(current) + const runtime = this.pythonLocator.findRuntime() + + return { + locale, + locales: this.translations.locales, + messages: this.translations.readBundle(locale), + navigationOpen: this.preferences.readNavigationOpen(), + pythonVersion: runtime === null ? null : runtime.version, + currentStore: current === null ? null : this.storeMapper.toDto(current), + stores: this.storeMapper.toDtoList(stores), + engine: engine === null ? null : this.engineMapper.toDto(engine), + minimumEngineVersion: formatVersion(MINIMUM_ENGINE_VERSION), + registryUrl: this.provisioning.registryUrl, + defaultStoreRoot: this.selection.readDefaultStoreRoot(), + appVersion: this.environment.readVersion() + } + } +} diff --git a/src/application/services/CatalogService.ts b/src/application/services/CatalogService.ts new file mode 100644 index 0000000..add412f --- /dev/null +++ b/src/application/services/CatalogService.ts @@ -0,0 +1,54 @@ +import type { CatalogListing } from '../../domain/models/CatalogListing' +import type { EngineProgressListener } from '../../domain/models/EngineProgress' +import type { Game } from '../../domain/models/Game' +import type { StorePaths } from '../../domain/models/StorePaths' +import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway' +import type { StoreSelectionService } from './StoreSelectionService' + +/** + * The catalog of the store that is open. + * + * The last listing is kept so a launch can be resolved by name: the window asks + * for "pong", and the paths it would need to start it never leave this process. + */ +export class CatalogService { + private lastListing: CatalogListing | null = null + + public constructor ( + private readonly catalogGateway: StoreCatalogGateway, + private readonly selection: StoreSelectionService + ) {} + + public async listGames (progress?: EngineProgressListener): Promise { + const listing = await this.catalogGateway.listGames(this.selection.requireCurrentStore(), progress) + this.lastListing = listing + return listing + } + + public async readPaths (progress?: EngineProgressListener): Promise { + return this.catalogGateway.readPaths(this.selection.requireCurrentStore(), progress) + } + + public async syncGames (names: readonly string[], progress?: EngineProgressListener): Promise { + await this.catalogGateway.syncGames(this.selection.requireCurrentStore(), names, progress) + this.forgetListing() + } + + public async removeGame (name: string, progress?: EngineProgressListener): Promise { + await this.catalogGateway.removeGame(this.selection.requireCurrentStore(), name, progress) + this.forgetListing() + } + + public findGame (name: string): Game | null { + return this.lastListing?.games.find((game: Game): boolean => game.name === name) ?? null + } + + public findCatalogBaseUrl (): string { + return this.lastListing?.paths?.catalogBaseUrl ?? '' + } + + /** After a write the listing is stale; the window refreshes anyway. */ + public forgetListing (): void { + this.lastListing = null + } +} diff --git a/src/application/services/GameLaunchService.ts b/src/application/services/GameLaunchService.ts new file mode 100644 index 0000000..249d4ba --- /dev/null +++ b/src/application/services/GameLaunchService.ts @@ -0,0 +1,29 @@ +import type { GameLauncher } from '../../domain/ports/GameLauncher' +import type { CatalogService } from './CatalogService' + +/** + * Starting a title the window asked for by name. + * + * The name is all the window has; the launch target comes from the catalog this + * process last read. + */ +export class GameLaunchService { + public constructor ( + private readonly launcher: GameLauncher, + private readonly catalog: CatalogService + ) {} + + public async launchGame (name: string): Promise { + const game = this.catalog.findGame(name) + if (game === null) return false + return this.launcher.launchGame(game) + } + + public async openFolder (directory: string): Promise { + return this.launcher.openFolder(directory) + } + + public async openUrl (url: string): Promise { + return this.launcher.openUrl(url) + } +} diff --git a/src/application/services/PreferencesService.ts b/src/application/services/PreferencesService.ts new file mode 100644 index 0000000..0c6e496 --- /dev/null +++ b/src/application/services/PreferencesService.ts @@ -0,0 +1,51 @@ +import type { Preferences } from '../../domain/models/Preferences' +import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment' +import type { PreferencesRepository } from '../../domain/ports/PreferencesRepository' +import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog' +import type { Locale } from '../../shared/i18n/MessageBundle' + +/** + * What the client remembers, and what it falls back to. + * + * The reads never fail: an unreadable file, a language that no longer exists and a + * fresh install all produce the same defaults. + */ +export class PreferencesService { + public constructor ( + private readonly repository: PreferencesRepository, + private readonly environment: ApplicationEnvironment, + private readonly translations: TranslationCatalog = new TranslationCatalog() + ) {} + + public readLocale (): Locale { + const stored = this.repository.read().locale + return this.translations.resolveLocale(stored ?? this.environment.readSystemLocale()) + } + + public updateLocale (candidate: string): Locale { + const locale = this.translations.resolveLocale(candidate) + this.merge({ locale }) + return locale + } + + public readNavigationOpen (): boolean { + return this.repository.read().navigationOpen ?? true + } + + public updateNavigationOpen (open: boolean): boolean { + this.merge({ navigationOpen: open }) + return open + } + + public readStoreHome (): string | null { + return this.repository.read().storeHome ?? null + } + + public updateStoreHome (home: string): void { + this.merge({ storeHome: home }) + } + + private merge (changes: Preferences): void { + this.repository.write({ ...this.repository.read(), ...changes }) + } +} diff --git a/src/application/services/StoreProvisioningService.ts b/src/application/services/StoreProvisioningService.ts new file mode 100644 index 0000000..9453bb5 --- /dev/null +++ b/src/application/services/StoreProvisioningService.ts @@ -0,0 +1,41 @@ +import type { EngineProgressListener } from '../../domain/models/EngineProgress' +import type { InstalledStore } from '../../domain/models/InstalledStore' +import type { RegistryStore } from '../../domain/models/RegistryStore' +import { deriveStoreId } from '../../domain/models/StoreIdentity' +import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository' +import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller' +import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository' +import type { StoreSelectionService } from './StoreSelectionService' + +/** + * Getting a store onto this machine. + * + * Which stores exist is the site's answer — this asks the registry and installs + * what was chosen, into the folder the shell installer would have used. The newly + * installed store becomes the open one, so the window can carry straight on. + */ +export class StoreProvisioningService { + public constructor ( + private readonly registry: StoreRegistryRepository, + private readonly installer: StoreEngineInstaller, + private readonly stores: InstalledStoreRepository, + private readonly selection: StoreSelectionService + ) {} + + public get registryUrl (): string { + return this.registry.sourceUrl + } + + public async listAvailableStores (): Promise { + return this.registry.listStores() + } + + public async installStore ( + store: RegistryStore, + progress?: EngineProgressListener + ): Promise { + const home = this.stores.resolveDefaultHome(deriveStoreId(store)) + const installed = await this.installer.installEngine(home, store, progress) + return this.selection.adoptStore(installed) + } +} diff --git a/src/application/services/StoreSelectionService.ts b/src/application/services/StoreSelectionService.ts new file mode 100644 index 0000000..41d88b1 --- /dev/null +++ b/src/application/services/StoreSelectionService.ts @@ -0,0 +1,73 @@ +import { StoreMissingError } from '../../domain/errors/StoreMissingError' +import type { EngineVersion } from '../../domain/models/EngineVersion' +import type { InstalledStore } from '../../domain/models/InstalledStore' +import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository' +import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway' +import type { PreferencesService } from './PreferencesService' + +/** + * Which store is open. + * + * A machine can carry several: two catalogs, or the same catalog installed twice. + * The remembered one wins, so the window reopens where it was left; the current + * store is cached because every catalog call needs it and re-scanning the disk per + * call would be silly. + */ +export class StoreSelectionService { + private current: InstalledStore | null = null + + public constructor ( + private readonly stores: InstalledStoreRepository, + private readonly catalogGateway: StoreCatalogGateway, + private readonly preferences: PreferencesService + ) {} + + public listStores (): readonly InstalledStore[] { + return this.stores.findAll() + } + + /** The store to drive, remembering the choice across runs. Null when there is none. */ + public findCurrentStore (): InstalledStore | null { + const known = this.stores.findAll() + const preferredHome = this.preferences.readStoreHome() + const remembered = preferredHome === null + ? undefined + : known.find((store: InstalledStore): boolean => store.home === preferredHome) + this.current = remembered ?? known[0] ?? null + return this.current + } + + public requireCurrentStore (): InstalledStore { + const store = this.current ?? this.findCurrentStore() + if (store === null) throw new StoreMissingError() + return store + } + + public selectStore (home: string): InstalledStore { + const store = this.stores.findByHome(home) + if (store === null) throw new StoreMissingError(home) + this.current = store + this.preferences.updateStoreHome(store.home) + return store + } + + /** Adopt a store that was just installed, without a disk scan. */ + public adoptStore (store: InstalledStore): InstalledStore { + this.current = store + this.preferences.updateStoreHome(store.home) + return store + } + + /** + * The engine version of a store, checked per store rather than once: two stores + * on one machine can be at different versions, and the one being switched to may + * be the older one. + */ + public findEngineVersion (store: InstalledStore): EngineVersion | null { + return this.catalogGateway.readEngineVersion(store) + } + + public readDefaultStoreRoot (): string { + return this.stores.readRoots()[0] ?? '' + } +} diff --git a/src/domain/errors/BusyError.ts b/src/domain/errors/BusyError.ts new file mode 100644 index 0000000..c94a37c --- /dev/null +++ b/src/domain/errors/BusyError.ts @@ -0,0 +1,10 @@ +import { DomainError } from './DomainError' + +/** A second engine call while one is running. The store writes files; two writers race. */ +export class BusyError extends DomainError { + public override readonly code: string = 'BUSY' + + public constructor () { + super('the store is busy with another operation') + } +} diff --git a/src/domain/errors/DomainError.ts b/src/domain/errors/DomainError.ts new file mode 100644 index 0000000..a77bb98 --- /dev/null +++ b/src/domain/errors/DomainError.ts @@ -0,0 +1,14 @@ +/** + * The base for every error this application raises on purpose. + * + * `code` is what crosses the bridge: the window shows its own sentence for a code + * it knows, and the message only ever ends up in the log drawer. + */ +export abstract class DomainError extends Error { + public abstract readonly code: string + + protected constructor (message: string) { + super(message) + this.name = new.target.name + } +} diff --git a/src/domain/errors/EngineInvocationError.ts b/src/domain/errors/EngineInvocationError.ts new file mode 100644 index 0000000..eb6d478 --- /dev/null +++ b/src/domain/errors/EngineInvocationError.ts @@ -0,0 +1,10 @@ +import { DomainError } from './DomainError' + +/** The engine ran and failed: a non-zero exit, or a process that never started. */ +export class EngineInvocationError extends DomainError { + public override readonly code: string = 'ENGINE_FAILED' + + public constructor (message: string, public readonly exitCode: number | null = null) { + super(message) + } +} diff --git a/src/domain/errors/PythonMissingError.ts b/src/domain/errors/PythonMissingError.ts new file mode 100644 index 0000000..8375913 --- /dev/null +++ b/src/domain/errors/PythonMissingError.ts @@ -0,0 +1,9 @@ +import { DomainError } from './DomainError' + +export class PythonMissingError extends DomainError { + public override readonly code: string = 'PYTHON_MISSING' + + public constructor () { + super('python3 was not found on this machine') + } +} diff --git a/src/domain/errors/RegistryUnavailableError.ts b/src/domain/errors/RegistryUnavailableError.ts new file mode 100644 index 0000000..ea9d79d --- /dev/null +++ b/src/domain/errors/RegistryUnavailableError.ts @@ -0,0 +1,9 @@ +import { DomainError } from './DomainError' + +export class RegistryUnavailableError extends DomainError { + public override readonly code: string = 'REGISTRY_UNAVAILABLE' + + public constructor (public readonly sourceUrl: string, reason: string) { + super(`${sourceUrl}: ${reason}`) + } +} diff --git a/src/domain/errors/StoreMissingError.ts b/src/domain/errors/StoreMissingError.ts new file mode 100644 index 0000000..edd9b03 --- /dev/null +++ b/src/domain/errors/StoreMissingError.ts @@ -0,0 +1,9 @@ +import { DomainError } from './DomainError' + +export class StoreMissingError extends DomainError { + public override readonly code: string = 'STORE_MISSING' + + public constructor (home?: string) { + super(home === undefined ? 'no store is installed yet' : `no store at ${home}`) + } +} diff --git a/src/domain/models/CatalogListing.ts b/src/domain/models/CatalogListing.ts new file mode 100644 index 0000000..aeb46fa --- /dev/null +++ b/src/domain/models/CatalogListing.ts @@ -0,0 +1,9 @@ +import type { Game } from './Game' +import type { StorePaths } from './StorePaths' + +/** One reading of a store's catalog. */ +export interface CatalogListing { + readonly games: readonly Game[] + readonly skipped: readonly string[] + readonly paths: StorePaths | null +} diff --git a/src/domain/models/EngineProgress.ts b/src/domain/models/EngineProgress.ts new file mode 100644 index 0000000..43d8f04 --- /dev/null +++ b/src/domain/models/EngineProgress.ts @@ -0,0 +1,13 @@ +import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto' + +/** + * How a long-running engine call reports itself. + * + * `onLog` is a line a person can read (the engine's stderr), `onEvent` one of its + * JSON progress events. Both are optional: a caller that only wants the result + * passes neither. + */ +export interface EngineProgressListener { + readonly onLog?: (line: string) => void + readonly onEvent?: (event: SyncEventDto) => void +} diff --git a/src/domain/models/EngineVersion.ts b/src/domain/models/EngineVersion.ts new file mode 100644 index 0000000..2555c2c --- /dev/null +++ b/src/domain/models/EngineVersion.ts @@ -0,0 +1,34 @@ +/** + * The engine version this client found, and whether it can drive it. + * + * `--json` arrived with engine 1.1.0. An older engine is not broken, it simply + * cannot be driven from a window — and it will be met in the wild, because the CLI + * shipped before this client did. + */ +export interface EngineVersion { + readonly text: string + readonly numbers: readonly number[] | null + readonly supported: boolean +} + +export const MINIMUM_ENGINE_VERSION: readonly number[] = [1, 1, 0] + +export function parseVersionNumbers (text: string): readonly number[] | null { + const match = /(\d+)\.(\d+)\.(\d+)/.exec(text) + return match ? match.slice(1, 4).map((part: string): number => Number(part)) : null +} + +export function isAtLeast (version: readonly number[] | null, minimum: readonly number[]): boolean { + if (version === null) return false + for (let index = 0; index < minimum.length; index += 1) { + const found = version[index] ?? 0 + const needed = minimum[index] ?? 0 + if (found > needed) return true + if (found < needed) return false + } + return true +} + +export function formatVersion (numbers: readonly number[]): string { + return numbers.join('.') +} diff --git a/src/domain/models/Game.ts b/src/domain/models/Game.ts new file mode 100644 index 0000000..1ad4474 --- /dev/null +++ b/src/domain/models/Game.ts @@ -0,0 +1,27 @@ +/** How a title runs: unpacked on this machine, or served as a web build. */ +export type GameMode = 'app' | 'web' + +/** + * A catalog entry, with what the store did about it on this machine. + * + * The launch targets live here and nowhere nearer the window: resolving what to + * open is the main process's job. + */ +export interface Game { + readonly name: string + readonly title: string + readonly platform: string + readonly version: string + readonly mode: GameMode + readonly kind: string + readonly description: string + readonly author: string + /** Relative to the catalog's base URL, as published. */ + readonly imagePath: string | null + readonly installed: boolean + readonly updateAvailable: boolean + readonly installedVersion: string | null + readonly menuEntryPath: string | null + readonly executablePath: string | null + readonly hostedUrl: string | null +} diff --git a/src/domain/models/InstalledStore.ts b/src/domain/models/InstalledStore.ts new file mode 100644 index 0000000..c0fd532 --- /dev/null +++ b/src/domain/models/InstalledStore.ts @@ -0,0 +1,9 @@ +/** A store engine installed on this machine, with everything needed to run it. */ +export interface InstalledStore { + readonly id: string + readonly name: string + readonly home: string + readonly scriptPath: string + readonly configPath: string + readonly engine: string +} diff --git a/src/domain/models/Preferences.ts b/src/domain/models/Preferences.ts new file mode 100644 index 0000000..fefaa52 --- /dev/null +++ b/src/domain/models/Preferences.ts @@ -0,0 +1,9 @@ +import type { Locale } from '../../shared/i18n/MessageBundle' + +/** What the client remembers between runs. Every field optional: a fresh install has none. */ +export interface Preferences { + readonly locale?: Locale + readonly navigationOpen?: boolean + /** The home of the store last opened, so the window reopens where it was left. */ + readonly storeHome?: string +} diff --git a/src/domain/models/PythonRuntime.ts b/src/domain/models/PythonRuntime.ts new file mode 100644 index 0000000..6185e55 --- /dev/null +++ b/src/domain/models/PythonRuntime.ts @@ -0,0 +1,6 @@ +/** The Python 3 this machine has, and how to invoke it. */ +export interface PythonRuntime { + readonly command: string + readonly arguments: readonly string[] + readonly version: string +} diff --git a/src/domain/models/RegistryStore.ts b/src/domain/models/RegistryStore.ts new file mode 100644 index 0000000..e00b19d --- /dev/null +++ b/src/domain/models/RegistryStore.ts @@ -0,0 +1,11 @@ +/** + * A store the site's registry offers. + * + * Three fields, because that is what a record is: what it is called, which + * catalog it serves, and where its configuration lives. + */ +export interface RegistryStore { + readonly name: string + readonly catalogUrl: string + readonly storeRepositoryUrl: string +} diff --git a/src/domain/models/StoreEngine.ts b/src/domain/models/StoreEngine.ts new file mode 100644 index 0000000..3e7a848 --- /dev/null +++ b/src/domain/models/StoreEngine.ts @@ -0,0 +1,23 @@ +/** + * A store engine this client knows how to drive. + * + * There is one today. The table exists because the RetroArch store has the same + * command shape, so a second entry — not a second code path — is what adding it + * would take. + */ +export interface StoreEngine { + readonly id: string + readonly scriptFileName: string + /** The installer names a store home ``. */ + readonly homeSuffix: string + readonly launcherSuffix: string +} + +export const DESKTOP_STORE_ENGINE: StoreEngine = { + id: 'desktop', + scriptFileName: 'desktop_store.py', + homeSuffix: '-desktop', + launcherSuffix: '-desktop-store' +} + +export const STORE_ENGINES: readonly StoreEngine[] = [DESKTOP_STORE_ENGINE] diff --git a/src/domain/models/StoreIdentity.ts b/src/domain/models/StoreIdentity.ts new file mode 100644 index 0000000..076364b --- /dev/null +++ b/src/domain/models/StoreIdentity.ts @@ -0,0 +1,15 @@ +import type { RegistryStore } from './RegistryStore' + +/** + * A store id from its repository name: `ttg-desktop-store` becomes `ttg`. + * + * The id names the store home and the folder games land in, so it has to be short + * and filesystem-safe. The repository name is the best source available before + * anything is downloaded; the store's own config.json overrides it once it is. + */ +export function deriveStoreId (store: RegistryStore): string { + const lastSegment = store.storeRepositoryUrl.replace(/\/+$/, '').split('/').pop() ?? '' + const base = lastSegment.replace(/-(desktop-)?store$/, '') || store.name + const slug = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') + return slug || 'store' +} diff --git a/src/domain/models/StorePaths.ts b/src/domain/models/StorePaths.ts new file mode 100644 index 0000000..9319a9c --- /dev/null +++ b/src/domain/models/StorePaths.ts @@ -0,0 +1,12 @@ +/** The store's resolved locations on this machine, as the engine reports them. */ +export interface StorePaths { + readonly operatingSystem: string + readonly architecture: string + readonly installRoot: string + readonly menuDirectory: string + readonly storeFolder: string + readonly menuGroup: string + readonly catalogBaseUrl: string + readonly storeName: string + readonly storeId: string +} diff --git a/src/domain/ports/ApplicationEnvironment.ts b/src/domain/ports/ApplicationEnvironment.ts new file mode 100644 index 0000000..65b987d --- /dev/null +++ b/src/domain/ports/ApplicationEnvironment.ts @@ -0,0 +1,6 @@ +/** What the host application knows about itself: its version, locale and storage. */ +export interface ApplicationEnvironment { + readVersion: () => string + readSystemLocale: () => string + resolveUserDataPath: (fileName: string) => string +} diff --git a/src/domain/ports/GameLauncher.ts b/src/domain/ports/GameLauncher.ts new file mode 100644 index 0000000..3bd0b67 --- /dev/null +++ b/src/domain/ports/GameLauncher.ts @@ -0,0 +1,8 @@ +import type { Game } from '../models/Game' + +/** Opening things outside this application: a game, a folder, a page. */ +export interface GameLauncher { + launchGame: (game: Game) => Promise + openFolder: (directory: string) => Promise + openUrl: (url: string) => Promise +} diff --git a/src/domain/ports/InstalledStoreRepository.ts b/src/domain/ports/InstalledStoreRepository.ts new file mode 100644 index 0000000..b7566f5 --- /dev/null +++ b/src/domain/ports/InstalledStoreRepository.ts @@ -0,0 +1,10 @@ +import type { InstalledStore } from '../models/InstalledStore' + +/** The stores present on this machine, wherever the shell installer would put them. */ +export interface InstalledStoreRepository { + findAll: () => readonly InstalledStore[] + findByHome: (home: string) => InstalledStore | null + /** The roots that are searched, in the order the shell installer would use them. */ + readRoots: () => readonly string[] + resolveDefaultHome: (storeId: string) => string +} diff --git a/src/domain/ports/PreferencesRepository.ts b/src/domain/ports/PreferencesRepository.ts new file mode 100644 index 0000000..524d960 --- /dev/null +++ b/src/domain/ports/PreferencesRepository.ts @@ -0,0 +1,6 @@ +import type { Preferences } from '../models/Preferences' + +export interface PreferencesRepository { + read: () => Preferences + write: (preferences: Preferences) => void +} diff --git a/src/domain/ports/PythonRuntimeLocator.ts b/src/domain/ports/PythonRuntimeLocator.ts new file mode 100644 index 0000000..91b3060 --- /dev/null +++ b/src/domain/ports/PythonRuntimeLocator.ts @@ -0,0 +1,5 @@ +import type { PythonRuntime } from '../models/PythonRuntime' + +export interface PythonRuntimeLocator { + findRuntime: () => PythonRuntime | null +} diff --git a/src/domain/ports/StoreCatalogGateway.ts b/src/domain/ports/StoreCatalogGateway.ts new file mode 100644 index 0000000..ce28dfc --- /dev/null +++ b/src/domain/ports/StoreCatalogGateway.ts @@ -0,0 +1,19 @@ +import type { CatalogListing } from '../models/CatalogListing' +import type { EngineProgressListener } from '../models/EngineProgress' +import type { EngineVersion } from '../models/EngineVersion' +import type { InstalledStore } from '../models/InstalledStore' +import type { StorePaths } from '../models/StorePaths' + +/** + * The store engine, as an interface. + * + * Every catalog operation this client performs is one call on this port; the CLI + * behind it stays the product, and nothing above this line knows it is Python. + */ +export interface StoreCatalogGateway { + listGames: (store: InstalledStore, progress?: EngineProgressListener) => Promise + readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise + syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise + removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise + readEngineVersion: (store: InstalledStore) => EngineVersion | null +} diff --git a/src/domain/ports/StoreEngineInstaller.ts b/src/domain/ports/StoreEngineInstaller.ts new file mode 100644 index 0000000..e7a3243 --- /dev/null +++ b/src/domain/ports/StoreEngineInstaller.ts @@ -0,0 +1,17 @@ +import type { EngineProgressListener } from '../models/EngineProgress' +import type { InstalledStore } from '../models/InstalledStore' +import type { RegistryStore } from '../models/RegistryStore' + +/** + * Setting up a store where there is none. + * + * This is why the client exists on Windows at all: the store's own installer is + * `curl … | sh`, which Windows does not have. + */ +export interface StoreEngineInstaller { + installEngine: ( + home: string, + store: RegistryStore, + progress?: EngineProgressListener + ) => Promise +} diff --git a/src/domain/ports/StoreRegistryRepository.ts b/src/domain/ports/StoreRegistryRepository.ts new file mode 100644 index 0000000..ea3d74d --- /dev/null +++ b/src/domain/ports/StoreRegistryRepository.ts @@ -0,0 +1,7 @@ +import type { RegistryStore } from '../models/RegistryStore' + +/** Which stores exist at all — the site's answer, not this client's. */ +export interface StoreRegistryRepository { + readonly sourceUrl: string + listStores: () => Promise +} diff --git a/src/infrastructure/electron/ElectronApplicationEnvironment.ts b/src/infrastructure/electron/ElectronApplicationEnvironment.ts new file mode 100644 index 0000000..9439c94 --- /dev/null +++ b/src/infrastructure/electron/ElectronApplicationEnvironment.ts @@ -0,0 +1,20 @@ +import path from 'node:path' +import type { App } from 'electron' +import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment' + +/** The host application, as the services see it. Keeps `electron` out of them. */ +export class ElectronApplicationEnvironment implements ApplicationEnvironment { + public constructor (private readonly app: App) {} + + public readVersion (): string { + return this.app.getVersion() + } + + public readSystemLocale (): string { + return this.app.getLocale() + } + + public resolveUserDataPath (fileName: string): string { + return path.join(this.app.getPath('userData'), fileName) + } +} diff --git a/src/infrastructure/electron/ElectronGameLauncher.ts b/src/infrastructure/electron/ElectronGameLauncher.ts new file mode 100644 index 0000000..260598e --- /dev/null +++ b/src/infrastructure/electron/ElectronGameLauncher.ts @@ -0,0 +1,58 @@ +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import type { Shell } from 'electron' +import type { Game } from '../../domain/models/Game' +import type { GameLauncher } from '../../domain/ports/GameLauncher' + +/** + * Launching what was installed. + * + * A hosted title is a URL, so it goes to the browser. A native one is whatever the + * store recorded: on macOS the app bundle through `open`, elsewhere the executable + * from its own directory — the same working directory the menu entry uses, because + * games load their assets relative to it. + */ +export class ElectronGameLauncher implements GameLauncher { + public constructor (private readonly shell: Shell) {} + + public async launchGame (game: Game): Promise { + if (game.mode === 'web' && game.hostedUrl !== null) { + await this.shell.openExternal(game.hostedUrl) + return true + } + + const target = game.menuEntryPath ?? game.executablePath + if (target === null || !fs.existsSync(target)) return false + + if (process.platform === 'darwin' && target.endsWith('.app')) { + this.spawnDetached('open', [target], path.dirname(target)) + return true + } + + if (process.platform === 'win32' || target.endsWith('.desktop')) { + const failure = await this.shell.openPath(target) + if (failure === '') return true + } + + const executable = game.executablePath ?? target + this.spawnDetached(executable, [], path.dirname(executable)) + return true + } + + public async openFolder (directory: string): Promise { + if (directory.length === 0) return false + const failure = await this.shell.openPath(directory) + return failure === '' + } + + public async openUrl (url: string): Promise { + if (!url.startsWith('https://')) return false + await this.shell.openExternal(url) + return true + } + + private spawnDetached (command: string, commandArguments: readonly string[], cwd: string): void { + spawn(command, [...commandArguments], { cwd, detached: true, stdio: 'ignore' }).unref() + } +} diff --git a/src/infrastructure/http/HttpTextClient.ts b/src/infrastructure/http/HttpTextClient.ts new file mode 100644 index 0000000..e388c8e --- /dev/null +++ b/src/infrastructure/http/HttpTextClient.ts @@ -0,0 +1,60 @@ +import http from 'node:http' +import https from 'node:https' + +const REQUEST_TIMEOUT_MS = 60_000 +const MAX_REDIRECTS = 5 +const USER_AGENT = 'warp-engine-desktop-gui' + +/** A response that arrived but said no. The status matters: 404 is not a failure everywhere. */ +export class HttpStatusError extends Error { + public constructor (public readonly url: string, public readonly statusCode: number) { + super(`${url} answered ${String(statusCode)}`) + this.name = 'HttpStatusError' + } +} + +/** + * GET a URL as text, following redirects. + * + * Node's own client rather than `fetch`, because this runs in the main process + * where the proxy and certificate settings are the system's, and because a moved + * repository answers 301. + */ +export class HttpTextClient { + public async readText (url: string, redirectsLeft: number = MAX_REDIRECTS): Promise { + return new Promise((resolve: (body: string) => void, reject: (error: Error) => void): void => { + const client = url.startsWith('http://') ? http : https + const request = client.get(url, { headers: { 'User-Agent': USER_AGENT } }, (response): void => { + const status = response.statusCode ?? 0 + const location = response.headers.location + + if (status >= 300 && status < 400 && location !== undefined) { + response.resume() + if (redirectsLeft <= 0) { + reject(new Error(`too many redirects for ${url}`)) + return + } + const next = new URL(location, url).toString() + this.readText(next, redirectsLeft - 1).then(resolve, reject) + return + } + + if (status !== 200) { + response.resume() + reject(new HttpStatusError(url, status)) + return + } + + let body = '' + response.setEncoding('utf8') + response.on('data', (chunk: string): void => { body += chunk }) + response.on('end', (): void => { resolve(body) }) + }) + + request.setTimeout(REQUEST_TIMEOUT_MS, (): void => { + request.destroy(new Error(`${url} timed out`)) + }) + request.on('error', reject) + }) + } +} diff --git a/src/infrastructure/json/JsonRecord.ts b/src/infrastructure/json/JsonRecord.ts new file mode 100644 index 0000000..fbd24dc --- /dev/null +++ b/src/infrastructure/json/JsonRecord.ts @@ -0,0 +1,53 @@ +/** + * Reading JSON that came from somewhere else. + * + * The engine's stdout and the site's registry are both outside this program, so + * their shape is a claim, not a fact. These readers turn `unknown` into typed + * values with a stated fallback, which keeps every parser honest and every mapper + * free of casts. + */ +export type JsonRecord = Readonly> + +export function asRecord (value: unknown): JsonRecord | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as JsonRecord) + : null +} + +export function readString (record: JsonRecord, key: string, fallback: string = ''): string { + const value = record[key] + return typeof value === 'string' ? value : fallback +} + +export function readOptionalString (record: JsonRecord, key: string): string | null { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function readBoolean (record: JsonRecord, key: string, fallback: boolean = false): boolean { + const value = record[key] + return typeof value === 'boolean' ? value : fallback +} + +export function readNumber (record: JsonRecord, key: string, fallback: number = 0): number { + const value = record[key] + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +export function readStringArray (record: JsonRecord, key: string): readonly string[] { + const value = record[key] + if (!Array.isArray(value)) return [] + return value.filter((item: unknown): item is string => typeof item === 'string') +} + +export function readRecordArray (record: JsonRecord, key: string): readonly JsonRecord[] { + const value = record[key] + if (!Array.isArray(value)) return [] + return value + .map((item: unknown): JsonRecord | null => asRecord(item)) + .filter((item: JsonRecord | null): item is JsonRecord => item !== null) +} + +export function readRecord (record: JsonRecord, key: string): JsonRecord | null { + return asRecord(record[key]) +} diff --git a/src/infrastructure/mappers/EngineGameMapper.ts b/src/infrastructure/mappers/EngineGameMapper.ts new file mode 100644 index 0000000..281976f --- /dev/null +++ b/src/infrastructure/mappers/EngineGameMapper.ts @@ -0,0 +1,36 @@ +import type { Game, GameMode } from '../../domain/models/Game' +import { + readBoolean, readOptionalString, readString, type JsonRecord +} from '../json/JsonRecord' + +/** + * One engine JSON entry to one domain model. + * + * The engine speaks snake_case and this is the only place that knows it: rename a + * field there and this mapper is the single file that follows. + */ +export class EngineGameMapper { + public toModel (record: JsonRecord): Game { + return { + name: readString(record, 'name'), + title: readString(record, 'title'), + platform: readString(record, 'platform'), + version: readString(record, 'version'), + mode: this.toMode(readString(record, 'mode')), + kind: readString(record, 'kind'), + description: readString(record, 'desc'), + author: readString(record, 'author'), + imagePath: readOptionalString(record, 'image_url'), + installed: readBoolean(record, 'installed'), + updateAvailable: readBoolean(record, 'update_available'), + installedVersion: readOptionalString(record, 'installed_version'), + menuEntryPath: readOptionalString(record, 'menu_entry'), + executablePath: readOptionalString(record, 'exe'), + hostedUrl: readOptionalString(record, 'url') + } + } + + private toMode (value: string): GameMode { + return value === 'web' ? 'web' : 'app' + } +} diff --git a/src/infrastructure/mappers/EngineStorePathsMapper.ts b/src/infrastructure/mappers/EngineStorePathsMapper.ts new file mode 100644 index 0000000..044c2b8 --- /dev/null +++ b/src/infrastructure/mappers/EngineStorePathsMapper.ts @@ -0,0 +1,20 @@ +import type { StorePaths } from '../../domain/models/StorePaths' +import { readRecord, readString, type JsonRecord } from '../json/JsonRecord' + +/** The engine's `paths` answer to one domain model. */ +export class EngineStorePathsMapper { + public toModel (record: JsonRecord): StorePaths { + const store = readRecord(record, 'store') + return { + operatingSystem: readString(record, 'os'), + architecture: readString(record, 'arch'), + installRoot: readString(record, 'install_root'), + menuDirectory: readString(record, 'menu_dir'), + storeFolder: readString(record, 'store_folder'), + menuGroup: readString(record, 'menu_group'), + catalogBaseUrl: store === null ? '' : readString(store, 'base_url'), + storeName: store === null ? '' : readString(store, 'name'), + storeId: store === null ? '' : readString(store, 'id') + } + } +} diff --git a/src/infrastructure/process/PythonEngineProcessRunner.ts b/src/infrastructure/process/PythonEngineProcessRunner.ts new file mode 100644 index 0000000..4bfc3b1 --- /dev/null +++ b/src/infrastructure/process/PythonEngineProcessRunner.ts @@ -0,0 +1,128 @@ +import { spawn, spawnSync } from 'node:child_process' +import { EngineInvocationError } from '../../domain/errors/EngineInvocationError' +import { PythonMissingError } from '../../domain/errors/PythonMissingError' +import type { EngineProgressListener } from '../../domain/models/EngineProgress' +import type { InstalledStore } from '../../domain/models/InstalledStore' +import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator' +import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto' +import { asRecord, type JsonRecord } from '../json/JsonRecord' + +const VERSION_PROBE_TIMEOUT_MS = 15_000 + +/** + * Runs one engine command and reads its two streams. + * + * The engine's contract with any client is `--json`: data on stdout, one JSON + * object per line, and the human-readable log on stderr. So nothing here parses a + * sentence meant for a person, and a line that is not JSON is handed to the log + * rather than crashing the call. + */ +export class PythonEngineProcessRunner { + public constructor (private readonly runtimeLocator: PythonRuntimeLocator) {} + + public async runCommand ( + store: InstalledStore, + commandArguments: readonly string[], + progress: EngineProgressListener = {} + ): Promise { + const runtime = this.runtimeLocator.findRuntime() + if (runtime === null) throw new PythonMissingError() + + const argv = [ + ...runtime.arguments, + store.scriptPath, + '--config', store.configPath, + '--json', + ...commandArguments + ] + + return new Promise(( + resolve: (records: readonly JsonRecord[]) => void, + reject: (error: Error) => void + ): void => { + const child = spawn(runtime.command, argv, { + env: { ...process.env, DESKTOP_STORE_HOME: store.home } + }) + const records: JsonRecord[] = [] + let stdoutRest = '' + let stderrRest = '' + + const takeStdout = (chunk: string): void => { + stdoutRest += chunk + const parts = stdoutRest.split('\n') + stdoutRest = parts.pop() ?? '' + for (const part of parts) this.consumeStdoutLine(part, records, progress) + } + + const takeStderr = (chunk: string): void => { + stderrRest += chunk + const parts = stderrRest.split('\n') + stderrRest = parts.pop() ?? '' + for (const part of parts) { + if (part.trim().length > 0) progress.onLog?.(part) + } + } + + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', takeStdout) + child.stderr.on('data', takeStderr) + child.on('error', (error: Error): void => { + reject(new EngineInvocationError(error.message)) + }) + child.on('close', (code: number | null): void => { + takeStdout('\n') + takeStderr('\n') + if (code === 0) resolve(records) + else reject(new EngineInvocationError(`the store exited with code ${String(code)}`, code)) + }) + }) + } + + /** The engine's `--version`, read synchronously because it gates the first paint. */ + public readVersionText (store: InstalledStore): string | null { + const runtime = this.runtimeLocator.findRuntime() + if (runtime === null) return null + try { + const probe = spawnSync(runtime.command, [...runtime.arguments, store.scriptPath, '--version'], { + encoding: 'utf8', + timeout: VERSION_PROBE_TIMEOUT_MS + }) + const text = `${probe.stdout}${probe.stderr}`.trim() + return probe.status === 0 && text.length > 0 ? text : null + } catch { + return null + } + } + + private consumeStdoutLine ( + line: string, + records: JsonRecord[], + progress: EngineProgressListener + ): void { + if (line.trim().length === 0) return + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + // Not ours to interpret — hand it on as a log line rather than fail the call. + progress.onLog?.(line) + return + } + const record = asRecord(parsed) + if (record === null) return + records.push(record) + const event = this.asSyncEvent(record) + if (event !== null) progress.onEvent?.(event) + } + + /** + * A progress line, or null for the final result object. + * + * The engine tags its stream events with `event`; the listing and paths answers + * carry no such field, which is exactly the difference. + */ + private asSyncEvent (record: JsonRecord): SyncEventDto | null { + return typeof record['event'] === 'string' ? (record as unknown as SyncEventDto) : null + } +} diff --git a/src/infrastructure/process/SystemPythonRuntimeLocator.ts b/src/infrastructure/process/SystemPythonRuntimeLocator.ts new file mode 100644 index 0000000..89a54f2 --- /dev/null +++ b/src/infrastructure/process/SystemPythonRuntimeLocator.ts @@ -0,0 +1,63 @@ +import { spawnSync } from 'node:child_process' +import type { PythonRuntime } from '../../domain/models/PythonRuntime' +import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator' + +interface RuntimeCandidate { + readonly command: string + readonly arguments: readonly string[] +} + +/** `py -3` is the Windows launcher, and often the only Python on PATH there. */ +const WINDOWS_CANDIDATES: readonly RuntimeCandidate[] = [ + { command: 'py', arguments: ['-3'] }, + { command: 'python', arguments: [] }, + { command: 'python3', arguments: [] } +] + +const POSIX_CANDIDATES: readonly RuntimeCandidate[] = [ + { command: 'python3', arguments: [] }, + { command: 'python', arguments: [] } +] + +const PROBE_TIMEOUT_MS = 10_000 + +/** + * Finds the Python 3 the store needs. + * + * The answer is cached: the probe spawns processes, and the window asks for it on + * every state read. + */ +export class SystemPythonRuntimeLocator implements PythonRuntimeLocator { + private cached: PythonRuntime | null = null + private probed = false + + public findRuntime (): PythonRuntime | null { + if (this.probed) return this.cached + this.probed = true + const candidates = process.platform === 'win32' ? WINDOWS_CANDIDATES : POSIX_CANDIDATES + for (const candidate of candidates) { + const runtime = this.probeCandidate(candidate) + if (runtime !== null) { + this.cached = runtime + return runtime + } + } + return null + } + + private probeCandidate (candidate: RuntimeCandidate): PythonRuntime | null { + try { + const probe = spawnSync(candidate.command, [...candidate.arguments, '--version'], { + encoding: 'utf8', + timeout: PROBE_TIMEOUT_MS + }) + const output = `${probe.stdout}${probe.stderr}` + if (probe.status === 0 && output.includes('Python 3.')) { + return { command: candidate.command, arguments: candidate.arguments, version: output.trim() } + } + } catch { + // An absent interpreter is the normal case, not an error worth reporting. + } + return null + } +} diff --git a/src/infrastructure/repositories/FileSystemInstalledStoreRepository.ts b/src/infrastructure/repositories/FileSystemInstalledStoreRepository.ts new file mode 100644 index 0000000..a64c365 --- /dev/null +++ b/src/infrastructure/repositories/FileSystemInstalledStoreRepository.ts @@ -0,0 +1,107 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { InstalledStore } from '../../domain/models/InstalledStore' +import { DESKTOP_STORE_ENGINE, STORE_ENGINES } from '../../domain/models/StoreEngine' +import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository' +import { asRecord, readString } from '../json/JsonRecord' + +const STORE_DIRECTORY_NAME = 'warp-engine-store' +const CONFIG_FILE_NAME = 'config.json' + +/** + * Finds stores where the shell installers put them. + * + * The roots are searched in the installers' own order, and `STORE_ROOT` comes + * first so a sandbox can be driven without touching a working installation — which + * is how this repository is tested. + */ +export class FileSystemInstalledStoreRepository implements InstalledStoreRepository { + public findAll (): readonly InstalledStore[] { + const found: InstalledStore[] = [] + for (const root of this.readRoots()) { + for (const entry of this.readDirectories(root)) { + const home = path.join(root, entry) + const store = this.readStoreAt(home, entry) + if (store !== null) found.push(store) + } + } + return found + } + + public findByHome (home: string): InstalledStore | null { + return this.findAll().find((store: InstalledStore): boolean => store.home === home) ?? null + } + + public readRoots (): readonly string[] { + const home = os.homedir() + const roots: string[] = [] + const override = process.env['STORE_ROOT'] + if (override !== undefined && override.length > 0) roots.push(override) + const xdgDataHome = process.env['XDG_DATA_HOME'] + if (xdgDataHome !== undefined && xdgDataHome.length > 0) { + roots.push(path.join(xdgDataHome, STORE_DIRECTORY_NAME)) + } + roots.push(path.join(home, '.local', 'share', STORE_DIRECTORY_NAME)) + if (process.platform === 'darwin') { + roots.push(path.join(home, 'Library', 'Application Support', STORE_DIRECTORY_NAME)) + } + const localAppData = process.env['LOCALAPPDATA'] + if (process.platform === 'win32' && localAppData !== undefined && localAppData.length > 0) { + roots.push(path.join(localAppData, STORE_DIRECTORY_NAME)) + } + return [...new Set(roots)] + } + + public resolveDefaultHome (storeId: string): string { + const root = this.readRoots()[0] ?? path.join(os.homedir(), '.local', 'share', STORE_DIRECTORY_NAME) + return path.join(root, `${storeId}${DESKTOP_STORE_ENGINE.homeSuffix}`) + } + + private readDirectories (root: string): readonly string[] { + try { + return fs.readdirSync(root, { withFileTypes: true }) + .filter((entry: fs.Dirent): boolean => entry.isDirectory()) + .map((entry: fs.Dirent): string => entry.name) + } catch { + // A root that does not exist is the normal case on a fresh machine. + return [] + } + } + + private readStoreAt (home: string, directoryName: string): InstalledStore | null { + for (const engine of STORE_ENGINES) { + const scriptPath = path.join(home, engine.scriptFileName) + const configPath = path.join(home, CONFIG_FILE_NAME) + if (!fs.existsSync(scriptPath) || !fs.existsSync(configPath)) continue + const id = directoryName.replace(engine.homeSuffix, '') + return { + id, + name: this.readStoreName(configPath, id), + home, + scriptPath, + configPath, + engine: engine.id + } + } + return null + } + + /** + * The store's own name, from the config the installer wrote. + * + * Read here rather than asked of the engine: the switcher lists every store on + * the machine, and starting a Python process per entry to learn its name would + * be absurd. + */ + private readStoreName (configPath: string, fallback: string): string { + try { + const config = asRecord(JSON.parse(fs.readFileSync(configPath, 'utf8'))) + const store = config === null ? null : asRecord(config['store']) + const name = store === null ? '' : readString(store, 'name') + return name.length > 0 ? name : fallback + } catch { + return fallback + } + } +} diff --git a/src/infrastructure/repositories/HttpStoreEngineInstaller.ts b/src/infrastructure/repositories/HttpStoreEngineInstaller.ts new file mode 100644 index 0000000..d227734 --- /dev/null +++ b/src/infrastructure/repositories/HttpStoreEngineInstaller.ts @@ -0,0 +1,117 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { EngineProgressListener } from '../../domain/models/EngineProgress' +import type { InstalledStore } from '../../domain/models/InstalledStore' +import type { RegistryStore } from '../../domain/models/RegistryStore' +import { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine' +import { deriveStoreId } from '../../domain/models/StoreIdentity' +import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller' +import { asRecord, readString } from '../json/JsonRecord' +import { HttpStatusError, type HttpTextClient } from '../http/HttpTextClient' + +const CONFIG_FILE_NAME = 'config.json' +const SCRIPT_MODE = 0o755 +const PYTHON_SHEBANG = '#!/usr/bin/env python3' +const DEFAULT_FORGE_BASE = 'https://git.teletypegames.org' +const DEFAULT_BRANCH = 'master' + +/** + * Downloads the engine, the shared core and a store config into a store home. + * + * The same three files, in the same folder, the shell installer would place — so + * the CLI and this client stay one installation, and running install.sh afterwards + * only adds the launcher script. No launcher is written here: the window is it. + */ +export class HttpStoreEngineInstaller implements StoreEngineInstaller { + private readonly forgeBase: string + + public constructor (private readonly httpClient: HttpTextClient, forgeBase?: string) { + const configured = process.env['FORGE_BASE'] + this.forgeBase = forgeBase ?? (configured !== undefined && configured.length > 0 + ? configured + : DEFAULT_FORGE_BASE) + } + + public async installEngine ( + home: string, + store: RegistryStore, + progress: EngineProgressListener = {} + ): Promise { + fs.mkdirSync(home, { recursive: true }) + + for (const [fileName, url] of Object.entries(this.engineSources())) { + progress.onLog?.(`downloading ${fileName}`) + const body = await this.httpClient.readText(url) + if (!body.startsWith(PYTHON_SHEBANG)) { + throw new Error(`${fileName} does not look like the store engine — refusing to install it`) + } + fs.writeFileSync(path.join(home, fileName), body, { mode: SCRIPT_MODE }) + } + + const config = await this.readStoreConfig(store, progress) + const configPath = path.join(home, CONFIG_FILE_NAME) + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + + progress.onLog?.(`${store.name} is set up in ${home}`) + const configStore = asRecord(config['store']) + return { + id: configStore === null ? deriveStoreId(store) : readString(configStore, 'id', deriveStoreId(store)), + name: store.name, + home, + scriptPath: path.join(home, DESKTOP_STORE_ENGINE.scriptFileName), + configPath, + engine: DESKTOP_STORE_ENGINE.id + } + } + + /** Where the engine itself comes from: this client's own machinery, not the registry's. */ + private engineSources (): Readonly> { + return { + [DESKTOP_STORE_ENGINE.scriptFileName]: + `${this.forgeBase}/stores/warp-engine-desktop-store/raw/branch/${DEFAULT_BRANCH}/${DESKTOP_STORE_ENGINE.scriptFileName}`, + 'warpstore.py': `${this.forgeBase}/engines/warpstore/raw/branch/${DEFAULT_BRANCH}/warpstore.py` + } + } + + /** + * The store's configuration. + * + * Its repository is the authority on how the store behaves — which platforms, + * which statuses, where things land. A repository without a config.json still + * works: the engine merges whatever it is given onto its own defaults, so a + * three-field config is a complete one. The registry wins on identity and on + * which catalog to read. + */ + private async readStoreConfig ( + store: RegistryStore, + progress: EngineProgressListener + ): Promise> { + const storeId = deriveStoreId(store) + let config: Record + try { + progress.onLog?.(`reading the store config from ${store.storeRepositoryUrl}`) + const body = await this.httpClient.readText(this.configUrl(store.storeRepositoryUrl)) + config = { ...(asRecord(JSON.parse(body)) ?? {}) } + } catch (error: unknown) { + if (!(error instanceof HttpStatusError) || error.statusCode !== 404) throw error + progress.onLog?.('no config.json in the store repository — using the engine defaults') + config = { + paths: { subfolder: storeId }, + catalog: { statuses: ['released', 'archived', 'demo'] } + } + } + + const existing = asRecord(config['store']) ?? {} + config['store'] = { + ...existing, + id: readString(existing, 'id', storeId), + name: store.name, + base_url: store.catalogUrl + } + return config + } + + private configUrl (repositoryUrl: string, branch: string = DEFAULT_BRANCH): string { + return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/${CONFIG_FILE_NAME}` + } +} diff --git a/src/infrastructure/repositories/HttpStoreRegistryRepository.ts b/src/infrastructure/repositories/HttpStoreRegistryRepository.ts new file mode 100644 index 0000000..23d96fe --- /dev/null +++ b/src/infrastructure/repositories/HttpStoreRegistryRepository.ts @@ -0,0 +1,47 @@ +import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailableError' +import type { RegistryStore } from '../../domain/models/RegistryStore' +import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository' +import { asRecord, readString, type JsonRecord } from '../json/JsonRecord' +import type { HttpTextClient } from '../http/HttpTextClient' + +const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores' + +/** + * The registry: `GET /api/stores` on the site. + * + * The one address this client knows, and even that is overridable — `STORES_API` + * points it at another site or at a local endpoint. Records missing any of the + * three fields are dropped rather than half-used. + */ +export class HttpStoreRegistryRepository implements StoreRegistryRepository { + public readonly sourceUrl: string + + public constructor (private readonly httpClient: HttpTextClient, sourceUrl?: string) { + const configured = process.env['STORES_API'] + this.sourceUrl = sourceUrl ?? (configured !== undefined && configured.length > 0 + ? configured + : DEFAULT_REGISTRY_URL) + } + + public async listStores (): Promise { + const body = await this.httpClient.readText(this.sourceUrl) + const parsed: unknown = JSON.parse(body) + if (!Array.isArray(parsed)) { + throw new RegistryUnavailableError(this.sourceUrl, 'the answer was not a list of stores') + } + return parsed + .map((row: unknown): JsonRecord | null => asRecord(row)) + .filter((row: JsonRecord | null): row is JsonRecord => row !== null) + .map((row: JsonRecord): RegistryStore => ({ + name: readString(row, 'name').trim(), + // Both spellings, because a registry is someone else's API: ours answers + // camelCase, and a hand-rolled one may not. + catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(), + storeRepositoryUrl: ( + readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url') + ).trim() + })) + .filter((store: RegistryStore): boolean => + store.name.length > 0 && store.catalogUrl.length > 0 && store.storeRepositoryUrl.length > 0) + } +} diff --git a/src/infrastructure/repositories/JsonFilePreferencesRepository.ts b/src/infrastructure/repositories/JsonFilePreferencesRepository.ts new file mode 100644 index 0000000..fc33b8d --- /dev/null +++ b/src/infrastructure/repositories/JsonFilePreferencesRepository.ts @@ -0,0 +1,56 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { Preferences } from '../../domain/models/Preferences' +import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment' +import type { PreferencesRepository } from '../../domain/ports/PreferencesRepository' +import { LOCALES, type Locale } from '../../shared/i18n/MessageBundle' +import { asRecord, readBoolean, readOptionalString } from '../json/JsonRecord' + +const PREFERENCES_FILE_NAME = 'prefs.json' + +/** + * Preferences in one small JSON file next to the application's own data. + * + * A lost preference is not worth an error dialog, so both directions swallow their + * failures — the defaults are always usable. + */ +export class JsonFilePreferencesRepository implements PreferencesRepository { + public constructor (private readonly environment: ApplicationEnvironment) {} + + public read (): Preferences { + try { + const parsed = asRecord(JSON.parse(fs.readFileSync(this.filePath(), 'utf8'))) + if (parsed === null) return {} + const locale = readOptionalString(parsed, 'locale') + const storeHome = readOptionalString(parsed, 'storeHome') + const preferences: { + locale?: Locale + navigationOpen?: boolean + storeHome?: string + } = {} + const known = LOCALES.find((candidate: Locale): boolean => candidate === locale) + if (known !== undefined) preferences.locale = known + if (storeHome !== null) preferences.storeHome = storeHome + if (typeof parsed['navigationOpen'] === 'boolean') { + preferences.navigationOpen = readBoolean(parsed, 'navigationOpen', true) + } + return preferences + } catch { + return {} + } + } + + public write (preferences: Preferences): void { + try { + const target = this.filePath() + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, `${JSON.stringify(preferences, null, 2)}\n`) + } catch { + // Not worth interrupting the session over. + } + } + + private filePath (): string { + return this.environment.resolveUserDataPath(PREFERENCES_FILE_NAME) + } +} diff --git a/src/infrastructure/repositories/PythonStoreCatalogGateway.ts b/src/infrastructure/repositories/PythonStoreCatalogGateway.ts new file mode 100644 index 0000000..2280010 --- /dev/null +++ b/src/infrastructure/repositories/PythonStoreCatalogGateway.ts @@ -0,0 +1,87 @@ +import type { CatalogListing } from '../../domain/models/CatalogListing' +import type { EngineProgressListener } from '../../domain/models/EngineProgress' +import { + MINIMUM_ENGINE_VERSION, isAtLeast, parseVersionNumbers, type EngineVersion +} from '../../domain/models/EngineVersion' +import type { Game } from '../../domain/models/Game' +import type { InstalledStore } from '../../domain/models/InstalledStore' +import type { StorePaths } from '../../domain/models/StorePaths' +import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway' +import { readRecord, readRecordArray, readStringArray, type JsonRecord } from '../json/JsonRecord' +import { EngineGameMapper } from '../mappers/EngineGameMapper' +import { EngineStorePathsMapper } from '../mappers/EngineStorePathsMapper' +import type { PythonEngineProcessRunner } from '../process/PythonEngineProcessRunner' + +/** + * The store engine, driven as a child process. + * + * The only adapter that knows the CLI exists. Everything above it sees the port. + */ +export class PythonStoreCatalogGateway implements StoreCatalogGateway { + public constructor ( + private readonly runner: PythonEngineProcessRunner, + private readonly gameMapper: EngineGameMapper = new EngineGameMapper(), + private readonly pathsMapper: EngineStorePathsMapper = new EngineStorePathsMapper() + ) {} + + public async listGames ( + store: InstalledStore, + progress?: EngineProgressListener + ): Promise { + const records = await this.runner.runCommand(store, ['list'], progress) + const answer = this.lastRecord(records) + if (answer === null) return { games: [], skipped: [], paths: null } + const games: readonly Game[] = readRecordArray(answer, 'games') + .map((record: JsonRecord): Game => this.gameMapper.toModel(record)) + const paths = readRecord(answer, 'paths') + return { + games, + skipped: readStringArray(answer, 'skipped'), + paths: paths === null ? null : this.pathsMapper.toModel(paths) + } + } + + public async readPaths ( + store: InstalledStore, + progress?: EngineProgressListener + ): Promise { + const records = await this.runner.runCommand(store, ['paths'], progress) + const answer = this.lastRecord(records) + return this.pathsMapper.toModel(answer ?? {}) + } + + public async syncGames ( + store: InstalledStore, + names: readonly string[], + progress?: EngineProgressListener + ): Promise { + await this.runner.runCommand(store, ['sync', ...names], progress) + } + + public async removeGame ( + store: InstalledStore, + name: string, + progress?: EngineProgressListener + ): Promise { + await this.runner.runCommand(store, ['remove', name], progress) + } + + public readEngineVersion (store: InstalledStore): EngineVersion | null { + const text = this.runner.readVersionText(store) + if (text === null) return null + const numbers = parseVersionNumbers(text) + return { text, numbers, supported: isAtLeast(numbers, MINIMUM_ENGINE_VERSION) } + } + + /** + * The result object is the last line: the stream events come first, and both + * arrive on the same pipe. + */ + private lastRecord (records: readonly JsonRecord[]): JsonRecord | null { + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index] + if (record !== undefined && typeof record['event'] !== 'string') return record + } + return null + } +} diff --git a/src/main/ElectronApplication.ts b/src/main/ElectronApplication.ts new file mode 100644 index 0000000..9a69c21 --- /dev/null +++ b/src/main/ElectronApplication.ts @@ -0,0 +1,96 @@ +import path from 'node:path' +import { BrowserWindow, dialog, type App, type IpcMain, type Shell } from 'electron' +import { ServiceContainer } from './composition/ServiceContainer' +import { MainWindowFactory } from './MainWindowFactory' +import { SelfTestRunner } from './diagnostics/SelfTestRunner' + +const SELFTEST_FLAG = '--selftest' +const SELFTEST_USER_DATA_DIRECTORY = 'warpstore-gui-selftest' +const PRODUCT_NAME = 'WarpEngine Store' + +/** + * The application's lifecycle. + * + * Thin on purpose: it owns the window and the process events, and hands everything + * else to the container. The self-test mode is part of the lifecycle because it has + * to bypass two of its rules — see below. + */ +export class ElectronApplication { + private readonly selfTest: boolean + private readonly container: ServiceContainer + private readonly windowFactory: MainWindowFactory + private window: BrowserWindow | null = null + + public constructor ( + private readonly app: App, + private readonly ipc: IpcMain, + shell: Shell, + argv: readonly string[] = process.argv + ) { + this.selfTest = argv.includes(SELFTEST_FLAG) + this.container = new ServiceContainer(app, shell) + this.windowFactory = new MainWindowFactory((message: string, level: number): void => { + if (level >= 2 || this.selfTest) console.log(`[renderer] ${message}`) + }) + } + + public start (): void { + // A test run must never be swallowed by a copy the user already has open: it + // gets its own user-data directory and skips the single-instance lock. Without + // this the second process exits silently with status 0, which reads as a pass. + if (this.selfTest) { + this.app.setPath('userData', path.join(this.app.getPath('temp'), SELFTEST_USER_DATA_DIRECTORY)) + } else if (!this.app.requestSingleInstanceLock()) { + this.app.quit() + return + } + + this.container.registerIpc(this.ipc) + this.app.on('second-instance', (): void => { this.focusWindow() }) + this.app.on('activate', (): void => { + if (BrowserWindow.getAllWindows().length === 0) this.openWindow() + }) + this.app.on('window-all-closed', (): void => { + if (process.platform !== 'darwin') this.app.quit() + }) + process.on('unhandledRejection', (reason: unknown): void => { + dialog.showErrorBox(PRODUCT_NAME, reason instanceof Error ? reason.message : String(reason)) + }) + + void this.app.whenReady().then((): void => { this.openWindow() }) + } + + private openWindow (): void { + const window = this.windowFactory.createWindow() + this.window = window + this.container.streams.attachWindow(window) + window.on('closed', (): void => { + this.container.streams.detachWindow() + this.window = null + }) + if (this.selfTest) this.scheduleSelfTest(window) + } + + private scheduleSelfTest (window: BrowserWindow): void { + const runner = new SelfTestRunner(window) + window.webContents.once('did-finish-load', (): void => { + // The first listing has to finish before there is anything to look at. + setTimeout((): void => { + runner.run().then( + (passed: boolean): void => { this.app.exit(passed ? 0 : 1) }, + (error: unknown): void => { + console.log(`SELFTEST ERROR ${error instanceof Error ? error.message : String(error)}`) + this.app.exit(1) + } + ) + }, runner.settleDelayMs) + }) + } + + private focusWindow (): void { + const window = this.window + if (window === null) return + if (window.isMinimized()) window.restore() + window.focus() + } +} diff --git a/src/main/MainWindowFactory.ts b/src/main/MainWindowFactory.ts new file mode 100644 index 0000000..8280bed --- /dev/null +++ b/src/main/MainWindowFactory.ts @@ -0,0 +1,63 @@ +import path from 'node:path' +import { BrowserWindow, shell, type BrowserWindowConstructorOptions } from 'electron' + +const WINDOW_OPTIONS: BrowserWindowConstructorOptions = { + width: 1040, + height: 720, + minWidth: 760, + minHeight: 520, + backgroundColor: '#11151c', + title: 'WarpEngine Store' +} + +/** + * The one window. + * + * Locked down deliberately: context isolation on, node integration off, sandbox on, + * and the page carries a CSP of its own. Nothing here should ever navigate away or + * open a second window — a link the user clicks goes to their browser instead. + */ +export class MainWindowFactory { + public constructor (private readonly onRendererMessage: (message: string, level: number) => void) {} + + public createWindow (): BrowserWindow { + const window = new BrowserWindow({ + ...WINDOW_OPTIONS, + webPreferences: { + preload: path.join(__dirname, '..', 'preload', 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true + } + }) + + void window.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')) + this.forwardRendererDiagnostics(window) + this.denyNavigation(window) + return window + } + + /** A renderer error is invisible from the main process otherwise. */ + private forwardRendererDiagnostics (window: BrowserWindow): void { + window.webContents.on('console-message', (details): void => { + const level = details.level === 'error' ? 3 : details.level === 'warning' ? 2 : 1 + this.onRendererMessage(details.message, level) + }) + window.webContents.on('render-process-gone', (_event, details): void => { + this.onRendererMessage(`gone: ${details.reason}`, 3) + }) + } + + private denyNavigation (window: BrowserWindow): void { + window.webContents.setWindowOpenHandler(({ url }: { url: string }): { action: 'deny' } => { + if (url.startsWith('https://')) void shell.openExternal(url) + return { action: 'deny' } + }) + window.webContents.on('will-navigate', (event, url: string): void => { + if (url === window.webContents.getURL()) return + event.preventDefault() + if (url.startsWith('https://')) void shell.openExternal(url) + }) + } +} diff --git a/src/main/composition/ServiceContainer.ts b/src/main/composition/ServiceContainer.ts new file mode 100644 index 0000000..09a7419 --- /dev/null +++ b/src/main/composition/ServiceContainer.ts @@ -0,0 +1,78 @@ +import type { App, IpcMain, Shell } from 'electron' +import { ApplicationStateService } from '../../application/services/ApplicationStateService' +import { CatalogService } from '../../application/services/CatalogService' +import { GameLaunchService } from '../../application/services/GameLaunchService' +import { PreferencesService } from '../../application/services/PreferencesService' +import { StoreProvisioningService } from '../../application/services/StoreProvisioningService' +import { StoreSelectionService } from '../../application/services/StoreSelectionService' +import { ElectronApplicationEnvironment } from '../../infrastructure/electron/ElectronApplicationEnvironment' +import { ElectronGameLauncher } from '../../infrastructure/electron/ElectronGameLauncher' +import { HttpTextClient } from '../../infrastructure/http/HttpTextClient' +import { PythonEngineProcessRunner } from '../../infrastructure/process/PythonEngineProcessRunner' +import { SystemPythonRuntimeLocator } from '../../infrastructure/process/SystemPythonRuntimeLocator' +import { FileSystemInstalledStoreRepository } from '../../infrastructure/repositories/FileSystemInstalledStoreRepository' +import { HttpStoreEngineInstaller } from '../../infrastructure/repositories/HttpStoreEngineInstaller' +import { HttpStoreRegistryRepository } from '../../infrastructure/repositories/HttpStoreRegistryRepository' +import { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository' +import { PythonStoreCatalogGateway } from '../../infrastructure/repositories/PythonStoreCatalogGateway' +import { AppIpcController } from '../ipc/AppIpcController' +import { CatalogIpcController } from '../ipc/CatalogIpcController' +import { IpcRouter } from '../ipc/IpcRouter' +import { SingleFlightGuard } from '../ipc/SingleFlightGuard' +import { StoreIpcController } from '../ipc/StoreIpcController' +import { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster' + +/** + * The composition root: the only file that knows which implementation backs which + * port. + * + * Every layer above depends on interfaces, so swapping the engine for a stub or the + * registry for a local endpoint is a change here and nowhere else. + */ +export class ServiceContainer { + public readonly streams: WindowStreamBroadcaster + public readonly guard: SingleFlightGuard + public readonly catalog: CatalogService + public readonly selection: StoreSelectionService + public readonly provisioning: StoreProvisioningService + public readonly state: ApplicationStateService + public readonly launching: GameLaunchService + + private readonly controllers: readonly { register: (router: IpcRouter) => void }[] + + public constructor (app: App, shell: Shell) { + this.streams = new WindowStreamBroadcaster() + this.guard = new SingleFlightGuard((busy: boolean): void => { this.streams.publishBusyChanged(busy) }) + + const environment = new ElectronApplicationEnvironment(app) + const httpClient = new HttpTextClient() + const pythonLocator = new SystemPythonRuntimeLocator() + const engineRunner = new PythonEngineProcessRunner(pythonLocator) + + const stores = new FileSystemInstalledStoreRepository() + const catalogGateway = new PythonStoreCatalogGateway(engineRunner) + const registry = new HttpStoreRegistryRepository(httpClient) + const installer = new HttpStoreEngineInstaller(httpClient) + const preferencesRepository = new JsonFilePreferencesRepository(environment) + + const preferences = new PreferencesService(preferencesRepository, environment) + this.selection = new StoreSelectionService(stores, catalogGateway, preferences) + this.catalog = new CatalogService(catalogGateway, this.selection) + this.provisioning = new StoreProvisioningService(registry, installer, stores, this.selection) + this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog) + this.state = new ApplicationStateService( + preferences, this.selection, this.provisioning, pythonLocator, environment + ) + + this.controllers = [ + new AppIpcController(this.state, preferences, this.launching), + new CatalogIpcController(this.catalog, this.launching, this.guard, this.streams), + new StoreIpcController(this.provisioning, this.selection, this.guard, this.streams) + ] + } + + public registerIpc (ipc: IpcMain): void { + const router = new IpcRouter(ipc) + for (const controller of this.controllers) controller.register(router) + } +} diff --git a/src/main/diagnostics/SelfTestRunner.ts b/src/main/diagnostics/SelfTestRunner.ts new file mode 100644 index 0000000..24e59d5 --- /dev/null +++ b/src/main/diagnostics/SelfTestRunner.ts @@ -0,0 +1,173 @@ +import fs from 'node:fs' +import type { BrowserWindow } from 'electron' +import { + asRecord, readBoolean, readNumber, readOptionalString, readString, readStringArray +} from '../../infrastructure/json/JsonRecord' + +const SETTLE_DELAY_MS = 6_000 +const SWITCH_SETTLE_DELAY_MS = 8_000 +const SHOT_FRAME_DELAY_MS = 400 + +/** What the window says about itself once it has painted. */ +interface SelfTestReport { + readonly cards: number + readonly installed: number + readonly buttons: number + readonly gateVisible: boolean + readonly gateTitle: string + readonly gateChoices: readonly string[] + readonly gateAction: string + readonly appName: string + readonly storeId: string + readonly navOpen: boolean + readonly stores: readonly string[] + readonly categories: readonly string[] + readonly activeCategory: string | null + readonly paths: string + readonly logLines: number + readonly locales: readonly string[] +} + +/** What changed after clicking a store that was not open. */ +interface StoreSwitchReport { + readonly storeId: string + readonly active: string | null + readonly cards: number + readonly categories: number +} + +/** + * Drives the window once and reports what rendered. + * + * This is the only check that would notice a renderer error at all: the main + * process log stays empty when the page throws. Counting nodes is not enough on its + * own — the collapsed-grid bug passed every count while showing neither box art nor + * buttons — so `SELFTEST_SHOT` has the window photograph itself for a human to look + * at. + */ +export class SelfTestRunner { + public constructor ( + private readonly window: BrowserWindow, + private readonly shotPath: string | null = process.env['SELFTEST_SHOT'] ?? null + ) {} + + public get settleDelayMs (): number { + return SETTLE_DELAY_MS + } + + /** True when the window is in a state a user could work with. */ + public async run (): Promise { + const report = await this.readReport() + console.log(JSON.stringify(report, null, 2)) + + const switched = report.stores.length > 1 ? await this.switchStore() : null + if (switched !== null) console.log(`switched: ${JSON.stringify(switched)}`) + + if (this.shotPath !== null) await this.captureShot(this.shotPath) + + const rendered = report.locales.length > 1 && ( + (report.cards > 0 && !report.gateVisible && report.stores.length > 0 && + report.categories.length > 0 && report.activeCategory !== null) || + (report.gateVisible && report.gateChoices.length > 0 && report.gateAction.length > 0)) + const switchedWell = switched === null || ( + switched.storeId.length > 0 && switched.storeId !== report.storeId && + switched.cards > 0 && switched.categories > 0) + + const passed = rendered && switchedWell + console.log(passed ? 'SELFTEST OK' : 'SELFTEST FAILED') + return passed + } + + private async readReport (): Promise { + const record = asRecord(JSON.parse(await this.evaluate(`JSON.stringify({ + cards: document.querySelectorAll('.card').length, + installed: document.querySelectorAll('.card.is-installed').length, + buttons: document.querySelectorAll('.card .actions button').length, + gateVisible: !document.getElementById('gate').hidden, + gateTitle: document.getElementById('gate-title').textContent, + gateChoices: [...document.getElementById('gate-select').options].map((option) => option.text), + gateAction: document.getElementById('gate-action').textContent, + appName: document.getElementById('app-name').textContent, + storeId: document.getElementById('store-id').textContent, + navOpen: !document.body.classList.contains('nav-closed'), + stores: [...document.querySelectorAll('#store-list .store-row')].map((row) => row.textContent), + categories: [...document.querySelectorAll('#cats .cat')].map((cat) => cat.textContent), + activeCategory: (document.querySelector('#cats .cat.is-active') || {}).textContent || null, + paths: document.getElementById('log-paths').textContent.slice(0, 120), + logLines: document.querySelectorAll('.log-line').length, + locales: [...document.getElementById('locale').options].map((option) => option.value) + })`))) ?? {} + + return { + cards: readNumber(record, 'cards'), + installed: readNumber(record, 'installed'), + buttons: readNumber(record, 'buttons'), + gateVisible: readBoolean(record, 'gateVisible'), + gateTitle: readString(record, 'gateTitle'), + gateChoices: readStringArray(record, 'gateChoices'), + gateAction: readString(record, 'gateAction'), + appName: readString(record, 'appName'), + storeId: readString(record, 'storeId'), + navOpen: readBoolean(record, 'navOpen'), + stores: readStringArray(record, 'stores'), + categories: readStringArray(record, 'categories'), + activeCategory: readOptionalString(record, 'activeCategory'), + paths: readString(record, 'paths'), + logLines: readNumber(record, 'logLines'), + locales: readStringArray(record, 'locales') + } + } + + /** + * With two stores on the machine the switcher is the thing most likely to break + * without anyone noticing, so the test uses it. Skipped with one store, which + * cannot be switched away from. + */ + private async switchStore (): Promise { + const record = asRecord(JSON.parse(await this.evaluate(`(async () => { + const other = [...document.querySelectorAll('#store-list .store-row')] + .find((row) => !row.classList.contains('is-active')) + other.click() + await new Promise((done) => setTimeout(done, ${String(SWITCH_SETTLE_DELAY_MS)})) + return JSON.stringify({ + storeId: document.getElementById('store-id').textContent, + active: (document.querySelector('#store-list .store-row.is-active') || {}).textContent || null, + cards: document.querySelectorAll('.card').length, + categories: document.querySelectorAll('#cats .cat').length + }) + })()`))) ?? {} + + return { + storeId: readString(record, 'storeId'), + active: readOptionalString(record, 'active'), + cards: readNumber(record, 'cards'), + categories: readNumber(record, 'categories') + } + } + + /** + * capturePage hands back the last painted frame, so a window that is behind + * others — or still loading box art — photographs as a half-drawn page. Focus it, + * wait for the images, then let one frame go by. + */ + private async captureShot (target: string): Promise { + this.window.show() + this.window.focus() + await this.evaluate(`(async () => { + await Promise.all([...document.images].map((image) => image.complete + ? null + : new Promise((done) => { image.onload = done; image.onerror = done }))) + await new Promise((done) => requestAnimationFrame(() => setTimeout(done, ${String(SHOT_FRAME_DELAY_MS)}))) + return String(document.images.length) + })()`) + const image = await this.window.webContents.capturePage() + fs.writeFileSync(target, image.toPNG()) + console.log(`shot: ${target}`) + } + + /** Every probe returns a JSON string, so nothing untyped crosses back. */ + private async evaluate (script: string): Promise { + const result: unknown = await this.window.webContents.executeJavaScript(script) + return typeof result === 'string' ? result : JSON.stringify(result ?? null) + } +} diff --git a/src/main/ipc/AppIpcController.ts b/src/main/ipc/AppIpcController.ts new file mode 100644 index 0000000..a6a7e13 --- /dev/null +++ b/src/main/ipc/AppIpcController.ts @@ -0,0 +1,52 @@ +import type { ApplicationStateService } from '../../application/services/ApplicationStateService' +import type { GameLaunchService } from '../../application/services/GameLaunchService' +import type { PreferencesService } from '../../application/services/PreferencesService' +import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels' +import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto' +import type { LocaleSelectionDto } from '../../shared/contracts/dto/LocaleSelectionDto' +import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog' +import { requireBoolean, requireString } from './IpcArguments' +import type { IpcRouter } from './IpcRouter' + +/** The window's own concerns: what it needs to paint, its language, its menu state. */ +export class AppIpcController { + public constructor ( + private readonly state: ApplicationStateService, + private readonly preferences: PreferencesService, + private readonly launching: GameLaunchService, + private readonly translations: TranslationCatalog = new TranslationCatalog() + ) {} + + public register (router: IpcRouter): void { + router.handle(IPC_CHANNELS.appReadState, (): AppStateDto => this.handleReadState()) + router.handle(IPC_CHANNELS.appUpdateLocale, (locale: unknown): LocaleSelectionDto => + this.handleUpdateLocale(requireString(locale, 'locale'))) + router.handle(IPC_CHANNELS.appUpdateNavOpen, (open: unknown): boolean => + this.handleUpdateNavOpen(requireBoolean(open, 'open'))) + router.handle(IPC_CHANNELS.appOpenFolder, async (directory: unknown): Promise => + this.handleOpenFolder(requireString(directory, 'directory'))) + router.handle(IPC_CHANNELS.appOpenUrl, async (url: unknown): Promise => + this.handleOpenUrl(requireString(url, 'url'))) + } + + private handleReadState (): AppStateDto { + return this.state.readState() + } + + private handleUpdateLocale (candidate: string): LocaleSelectionDto { + const locale = this.preferences.updateLocale(candidate) + return { locale, messages: this.translations.readBundle(locale) } + } + + private handleUpdateNavOpen (open: boolean): boolean { + return this.preferences.updateNavigationOpen(open) + } + + private async handleOpenFolder (directory: string): Promise { + return this.launching.openFolder(directory) + } + + private async handleOpenUrl (url: string): Promise { + return this.launching.openUrl(url) + } +} diff --git a/src/main/ipc/CatalogIpcController.ts b/src/main/ipc/CatalogIpcController.ts new file mode 100644 index 0000000..0ccde9e --- /dev/null +++ b/src/main/ipc/CatalogIpcController.ts @@ -0,0 +1,74 @@ +import type { CatalogService } from '../../application/services/CatalogService' +import type { GameLaunchService } from '../../application/services/GameLaunchService' +import { GameDtoMapper } from '../../application/mappers/GameDtoMapper' +import { StorePathsDtoMapper } from '../../application/mappers/StorePathsDtoMapper' +import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels' +import type { CatalogListingDto } from '../../shared/contracts/dto/CatalogListingDto' +import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto' +import type { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster' +import { requireString, requireStringArray } from './IpcArguments' +import type { IpcRouter } from './IpcRouter' +import type { SingleFlightGuard } from './SingleFlightGuard' + +/** + * Everything that touches the catalog. + * + * The four engine calls go through the guard; launching does not, because it starts + * someone else's program and writes nothing. + */ +export class CatalogIpcController { + public constructor ( + private readonly catalog: CatalogService, + private readonly launching: GameLaunchService, + private readonly guard: SingleFlightGuard, + private readonly streams: WindowStreamBroadcaster, + private readonly gameMapper: GameDtoMapper = new GameDtoMapper(), + private readonly pathsMapper: StorePathsDtoMapper = new StorePathsDtoMapper() + ) {} + + public register (router: IpcRouter): void { + router.handle(IPC_CHANNELS.catalogListGames, async (): Promise => + this.handleListGames()) + router.handle(IPC_CHANNELS.catalogReadPaths, async (): Promise => + this.handleReadPaths()) + router.handle(IPC_CHANNELS.catalogSyncGames, async (names: unknown): Promise => + this.handleSyncGames(names === undefined ? [] : requireStringArray(names, 'names'))) + router.handle(IPC_CHANNELS.catalogRemoveGame, async (name: unknown): Promise => + this.handleRemoveGame(requireString(name, 'name'))) + router.handle(IPC_CHANNELS.catalogLaunchGame, async (name: unknown): Promise => + this.handleLaunchGame(requireString(name, 'name'))) + } + + private async handleListGames (): Promise { + return this.guard.run(async (): Promise => { + const listing = await this.catalog.listGames(this.streams.asProgressListener()) + const baseUrl = listing.paths?.catalogBaseUrl ?? '' + return { + games: this.gameMapper.toDtoList(listing.games, baseUrl), + skipped: listing.skipped, + paths: listing.paths === null ? null : this.pathsMapper.toDto(listing.paths) + } + }) + } + + private async handleReadPaths (): Promise { + return this.guard.run(async (): Promise => + this.pathsMapper.toDto(await this.catalog.readPaths(this.streams.asProgressListener()))) + } + + private async handleSyncGames (names: readonly string[]): Promise { + await this.guard.run(async (): Promise => { + await this.catalog.syncGames(names, this.streams.asProgressListener()) + }) + } + + private async handleRemoveGame (name: string): Promise { + await this.guard.run(async (): Promise => { + await this.catalog.removeGame(name, this.streams.asProgressListener()) + }) + } + + private async handleLaunchGame (name: string): Promise { + return this.launching.launchGame(name) + } +} diff --git a/src/main/ipc/IpcArguments.ts b/src/main/ipc/IpcArguments.ts new file mode 100644 index 0000000..3302ce5 --- /dev/null +++ b/src/main/ipc/IpcArguments.ts @@ -0,0 +1,40 @@ +import { asRecord, readString } from '../../infrastructure/json/JsonRecord' +import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto' + +/** + * Reading what came over the bridge. + * + * The window is ours, but the channel is an interface: a payload is checked here + * once, so no service below has to wonder whether a string is really a string. + */ +export function requireString (value: unknown, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new TypeError(`${name} must be a non-empty string`) + } + return value +} + +export function requireBoolean (value: unknown, name: string): boolean { + if (typeof value !== 'boolean') throw new TypeError(`${name} must be a boolean`) + return value +} + +export function requireStringArray (value: unknown, name: string): readonly string[] { + if (!Array.isArray(value)) throw new TypeError(`${name} must be an array of strings`) + return value.map((item: unknown, index: number): string => requireString(item, `${name}[${String(index)}]`)) +} + +export function requireRegistryStore (value: unknown): RegistryStoreDto { + const record = asRecord(value) + if (record === null) throw new TypeError('a store record is required') + const store: RegistryStoreDto = { + name: readString(record, 'name'), + catalogUrl: readString(record, 'catalogUrl'), + storeRepositoryUrl: readString(record, 'storeRepositoryUrl'), + storeId: readString(record, 'storeId') + } + if (store.name.length === 0 || store.catalogUrl.length === 0 || store.storeRepositoryUrl.length === 0) { + throw new TypeError('a store record needs a name, a catalog URL and a repository URL') + } + return store +} diff --git a/src/main/ipc/IpcRouter.ts b/src/main/ipc/IpcRouter.ts new file mode 100644 index 0000000..ff28b57 --- /dev/null +++ b/src/main/ipc/IpcRouter.ts @@ -0,0 +1,37 @@ +import type { IpcMain, IpcMainInvokeEvent } from 'electron' +import { DomainError } from '../../domain/errors/DomainError' +import type { IpcChannel } from '../../shared/contracts/IpcChannels' + +/** What a channel does with the arguments it was invoked with. */ +export type IpcHandler = (...args: readonly unknown[]) => Promise | TResult + +/** + * The one place a channel is registered. + * + * Errors are normalised on the way out: a domain error crosses as `CODE: message` + * so the log drawer shows something a person can act on, and an unexpected one is + * logged here rather than vanishing into a rejected promise the window cannot read. + */ +export class IpcRouter { + public constructor (private readonly ipc: IpcMain) {} + + public handle(channel: IpcChannel, handler: IpcHandler): void { + this.ipc.handle(channel, async (_event: IpcMainInvokeEvent, ...args: readonly unknown[]): Promise => { + try { + return await handler(...args) + } catch (error: unknown) { + throw this.describe(channel, error) + } + }) + } + + private describe (channel: IpcChannel, error: unknown): Error { + if (error instanceof DomainError) return new Error(`${error.code}: ${error.message}`) + if (error instanceof Error) { + console.error(`[ipc] ${channel} failed: ${error.message}`) + return error + } + console.error(`[ipc] ${channel} failed: ${String(error)}`) + return new Error(String(error)) + } +} diff --git a/src/main/ipc/SingleFlightGuard.ts b/src/main/ipc/SingleFlightGuard.ts new file mode 100644 index 0000000..547f039 --- /dev/null +++ b/src/main/ipc/SingleFlightGuard.ts @@ -0,0 +1,30 @@ +import { BusyError } from '../../domain/errors/BusyError' + +/** + * One engine call at a time. + * + * The store writes files, and two writers would race. Callers are told which state + * the guard is in, so the window can disable exactly what would start a second + * call and leave the rest alive. + */ +export class SingleFlightGuard { + private running = false + + public constructor (private readonly onBusyChanged: (busy: boolean) => void) {} + + public get busy (): boolean { + return this.running + } + + public async run(task: () => Promise): Promise { + if (this.running) throw new BusyError() + this.running = true + this.onBusyChanged(true) + try { + return await task() + } finally { + this.running = false + this.onBusyChanged(false) + } + } +} diff --git a/src/main/ipc/StoreIpcController.ts b/src/main/ipc/StoreIpcController.ts new file mode 100644 index 0000000..392bfe1 --- /dev/null +++ b/src/main/ipc/StoreIpcController.ts @@ -0,0 +1,73 @@ +import { InstalledStoreDtoMapper } from '../../application/mappers/InstalledStoreDtoMapper' +import { EngineVersionDtoMapper } from '../../application/mappers/EngineVersionDtoMapper' +import { RegistryStoreDtoMapper } from '../../application/mappers/RegistryStoreDtoMapper' +import type { StoreProvisioningService } from '../../application/services/StoreProvisioningService' +import type { StoreSelectionService } from '../../application/services/StoreSelectionService' +import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels' +import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto' +import type { RegistryResultDto } from '../../shared/contracts/dto/RegistryResultDto' +import type { StoreSelectionDto } from '../../shared/contracts/dto/StoreSelectionDto' +import type { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster' +import { requireRegistryStore, requireString } from './IpcArguments' +import type { IpcRouter } from './IpcRouter' +import type { SingleFlightGuard } from './SingleFlightGuard' + +/** Which stores exist, which one is open, and installing a new one. */ +export class StoreIpcController { + public constructor ( + private readonly provisioning: StoreProvisioningService, + private readonly selection: StoreSelectionService, + private readonly guard: SingleFlightGuard, + private readonly streams: WindowStreamBroadcaster, + private readonly registryMapper: RegistryStoreDtoMapper = new RegistryStoreDtoMapper(), + private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper(), + private readonly engineMapper: EngineVersionDtoMapper = new EngineVersionDtoMapper() + ) {} + + public register (router: IpcRouter): void { + router.handle(IPC_CHANNELS.storeListRegistry, async (): Promise => + this.handleListRegistry()) + router.handle(IPC_CHANNELS.storeInstallStore, async (store: unknown): Promise => + this.handleInstallStore(store)) + router.handle(IPC_CHANNELS.storeSelectStore, (home: unknown): StoreSelectionDto => + this.handleSelectStore(requireString(home, 'home'))) + } + + /** + * The registry lookup never rejects: the window has to say *why* there is nothing + * to install, and an unreachable site and an empty list need different words. + */ + private async handleListRegistry (): Promise { + try { + const stores = await this.provisioning.listAvailableStores() + return { + stores: this.registryMapper.toDtoList(stores), + sourceUrl: this.provisioning.registryUrl, + error: null + } + } catch (error: unknown) { + return { + stores: [], + sourceUrl: this.provisioning.registryUrl, + error: error instanceof Error ? error.message : String(error) + } + } + } + + private async handleInstallStore (payload: unknown): Promise { + const chosen = this.registryMapper.toModel(requireRegistryStore(payload)) + return this.guard.run(async (): Promise => { + const installed = await this.provisioning.installStore(chosen, this.streams.asProgressListener()) + return this.storeMapper.toDto(installed) + }) + } + + private handleSelectStore (home: string): StoreSelectionDto { + const store = this.selection.selectStore(home) + const engine = this.selection.findEngineVersion(store) + return { + store: this.storeMapper.toDto(store), + engine: engine === null ? null : this.engineMapper.toDto(engine) + } + } +} diff --git a/src/main/main.ts b/src/main/main.ts new file mode 100644 index 0000000..dbaf858 --- /dev/null +++ b/src/main/main.ts @@ -0,0 +1,5 @@ +import { app, ipcMain, shell } from 'electron' +import { ElectronApplication } from './ElectronApplication' + +// The entry point does one thing: everything else is a class with a name. +new ElectronApplication(app, ipcMain, shell).start() diff --git a/src/main/streams/WindowStreamBroadcaster.ts b/src/main/streams/WindowStreamBroadcaster.ts new file mode 100644 index 0000000..567d102 --- /dev/null +++ b/src/main/streams/WindowStreamBroadcaster.ts @@ -0,0 +1,49 @@ +import type { BrowserWindow } from 'electron' +import type { EngineProgressListener } from '../../domain/models/EngineProgress' +import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels' +import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto' + +/** + * The three one-way streams to the window: log lines, progress events, busy state. + * + * Holds no window of its own — the reference is handed in when one exists and + * cleared when it does not, so a stream that outlives the window is a no-op rather + * than a crash. + */ +export class WindowStreamBroadcaster { + private window: BrowserWindow | null = null + + public attachWindow (window: BrowserWindow): void { + this.window = window + } + + public detachWindow (): void { + this.window = null + } + + public publishLog (line: string): void { + this.send(IPC_CHANNELS.streamLog, line) + } + + public publishSyncEvent (event: SyncEventDto): void { + this.send(IPC_CHANNELS.streamSyncEvent, event) + } + + public publishBusyChanged (busy: boolean): void { + this.send(IPC_CHANNELS.streamBusyChanged, busy) + } + + /** A progress listener wired to these streams, for handing to the engine. */ + public asProgressListener (): EngineProgressListener { + return { + onLog: (line: string): void => { this.publishLog(line) }, + onEvent: (event: SyncEventDto): void => { this.publishSyncEvent(event) } + } + } + + private send (channel: string, payload: unknown): void { + const window = this.window + if (window === null || window.isDestroyed()) return + window.webContents.send(channel, payload) + } +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts new file mode 100644 index 0000000..c7ee52e --- /dev/null +++ b/src/preload/preload.ts @@ -0,0 +1,72 @@ +import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' +import { + BRIDGE_GLOBAL_NAME, type BridgeApi, type StreamListener +} from '../shared/contracts/BridgeApi' +import { IPC_CHANNELS } from '../shared/contracts/IpcChannels' +import type { AppStateDto } from '../shared/contracts/dto/AppStateDto' +import type { CatalogListingDto } from '../shared/contracts/dto/CatalogListingDto' +import type { InstalledStoreDto } from '../shared/contracts/dto/InstalledStoreDto' +import type { LocaleSelectionDto } from '../shared/contracts/dto/LocaleSelectionDto' +import type { RegistryResultDto } from '../shared/contracts/dto/RegistryResultDto' +import type { RegistryStoreDto } from '../shared/contracts/dto/RegistryStoreDto' +import type { StorePathsDto } from '../shared/contracts/dto/StorePathsDto' +import type { StoreSelectionDto } from '../shared/contracts/dto/StoreSelectionDto' +import type { SyncEventDto } from '../shared/contracts/dto/SyncEventDto' + +/** + * The bridge, and nothing else. + * + * This file is the whole surface the window gets: no Node, no filesystem, no child + * processes. It is bundled into a single script on purpose — a sandboxed preload + * cannot require its own modules — and it implements `BridgeApi`, so the renderer + * and the main process are compiled against the same contract. + */ +const bridge: BridgeApi = { + readState: async (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.appReadState) as Promise, + updateLocale: async (locale: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.appUpdateLocale, locale) as Promise, + updateNavOpen: async (open: boolean): Promise => + ipcRenderer.invoke(IPC_CHANNELS.appUpdateNavOpen, open) as Promise, + + listGames: async (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.catalogListGames) as Promise, + readPaths: async (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.catalogReadPaths) as Promise, + syncGames: async (names: readonly string[]): Promise => + ipcRenderer.invoke(IPC_CHANNELS.catalogSyncGames, names) as Promise, + removeGame: async (name: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.catalogRemoveGame, name) as Promise, + launchGame: async (name: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.catalogLaunchGame, name) as Promise, + + listRegistryStores: async (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise, + installStore: async (store: RegistryStoreDto): Promise => + ipcRenderer.invoke(IPC_CHANNELS.storeInstallStore, store) as Promise, + selectStore: async (home: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.storeSelectStore, home) as Promise, + + openFolder: async (directory: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.appOpenFolder, directory) as Promise, + openUrl: async (url: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.appOpenUrl, url) as Promise, + + onLog: (listener: StreamListener): void => { + ipcRenderer.on(IPC_CHANNELS.streamLog, (_event: IpcRendererEvent, line: string): void => { + listener(line) + }) + }, + onSyncEvent: (listener: StreamListener): void => { + ipcRenderer.on(IPC_CHANNELS.streamSyncEvent, (_event: IpcRendererEvent, payload: SyncEventDto): void => { + listener(payload) + }) + }, + onBusyChanged: (listener: StreamListener): void => { + ipcRenderer.on(IPC_CHANNELS.streamBusyChanged, (_event: IpcRendererEvent, busy: boolean): void => { + listener(busy) + }) + } +} + +contextBridge.exposeInMainWorld(BRIDGE_GLOBAL_NAME, bridge) diff --git a/src/renderer/BridgeAccess.ts b/src/renderer/BridgeAccess.ts new file mode 100644 index 0000000..45554ff --- /dev/null +++ b/src/renderer/BridgeAccess.ts @@ -0,0 +1,21 @@ +import { BRIDGE_GLOBAL_NAME, type BridgeApi } from '../shared/contracts/BridgeApi' + +declare global { + interface Window { + readonly storeApi?: BridgeApi + } +} + +/** + * The bridge the preload published. + * + * Absent means the preload did not run, which is a packaging fault rather than a + * runtime condition — so it fails here, once, with a sentence that says what happened. + */ +export function requireBridge (): BridgeApi { + const bridge = window.storeApi + if (bridge === undefined) { + throw new Error(`window.${BRIDGE_GLOBAL_NAME} is missing — the preload script did not run`) + } + return bridge +} diff --git a/src/renderer/RendererApplication.ts b/src/renderer/RendererApplication.ts new file mode 100644 index 0000000..fff5a46 --- /dev/null +++ b/src/renderer/RendererApplication.ts @@ -0,0 +1,106 @@ +import type { BridgeApi } from '../shared/contracts/BridgeApi' +import { requireBridge } from './BridgeAccess' +import { CatalogController } from './controllers/CatalogController' +import { EngineStreamController } from './controllers/EngineStreamController' +import { PreferencesController } from './controllers/PreferencesController' +import { StoreController } from './controllers/StoreController' +import { AppStore, type AppState } from './state/AppStore' +import { buildCategorySections, resolveFilter, type CategoryFilter } from './state/CategoryFilter' +import { CatalogGridView } from './views/CatalogGridView' +import { GameCardView } from './views/GameCardView' +import { GateView } from './views/GateView' +import { LogDrawerView } from './views/LogDrawerView' +import { SideMenuView } from './views/SideMenuView' +import { TopBarView } from './views/TopBarView' + +/** + * The window, assembled. + * + * The flow is one direction only: a control calls a controller, the controller calls + * the bridge and writes to the store, and the store re-renders every view. No view + * reads another view, and nothing but the store decides what is on screen. + */ +export class RendererApplication { + private readonly store = new AppStore() + private readonly bridge: BridgeApi + private readonly log: LogDrawerView + private readonly gate: GateView + private readonly grid: CatalogGridView + private readonly topBar: TopBarView + private readonly sideMenu: SideMenuView + private readonly catalog: CatalogController + private readonly stores: StoreController + private readonly preferences: PreferencesController + private readonly streams: EngineStreamController + + public constructor (bridge: BridgeApi = requireBridge()) { + this.bridge = bridge + + this.log = new LogDrawerView({ + onOpenFolder: (directory: string): void => { void this.bridge.openFolder(directory) } + }) + this.gate = new GateView((url: string): void => { void this.bridge.openUrl(url) }) + this.catalog = new CatalogController(this.bridge, this.store, this.log) + this.stores = new StoreController(this.bridge, this.store, this.gate, this.log, this.catalog) + this.preferences = new PreferencesController(this.bridge, this.store) + this.streams = new EngineStreamController(this.bridge, this.store, this.log) + + this.grid = new CatalogGridView(new GameCardView({ + onInstall: (name: string): void => { void this.catalog.syncGames([name]) }, + onLaunch: (name: string): void => { void this.catalog.launchGame(name) }, + onRemove: (name: string): void => { void this.catalog.removeGame(name) } + })) + this.topBar = new TopBarView({ + onToggleNavigation: (): void => { void this.preferences.toggleNavigation() } + }) + this.sideMenu = new SideMenuView({ + onSelectStore: (home: string): void => { void this.stores.selectStore(home) }, + onAddStore: (): void => { void this.stores.offerStores() }, + onSyncAll: (): void => { void this.catalog.syncGames([]) }, + onRefresh: (): void => { void this.catalog.refresh() }, + onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) }, + onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) } + }) + + this.store.subscribe((state: AppState): void => { this.render(state) }) + this.streams.subscribe() + } + + /** Decides what the window is showing, then hands over to the views. */ + public async start (): Promise { + this.store.applyAppState(await this.bridge.readState()) + const state = this.store.readState() + + if (state.pythonVersion === null) { + this.stores.showMissingPythonGate() + return + } + if (state.currentStore === null) { + await this.stores.offerStores() + return + } + if (state.engine !== null && !state.engine.supported) { + this.stores.showOutdatedEngineGate() + return + } + this.gate.hide() + await this.catalog.refresh() + } + + private render (state: AppState): void { + // The filter is corrected before anything is drawn from it, so the menu and the + // grid can never disagree about which category is active. + const corrected = resolveFilter(state.filter, buildCategorySections(state.games, state.messages)) + if (corrected !== state.filter) { + this.store.applyFilter(corrected) + return + } + + document.body.classList.toggle('nav-closed', !state.navigationOpen) + this.topBar.render(state) + this.sideMenu.render(state) + this.log.render(state) + if (this.gate.visible) this.grid.hide() + else this.grid.render(state) + } +} diff --git a/src/renderer/controllers/CatalogController.ts b/src/renderer/controllers/CatalogController.ts new file mode 100644 index 0000000..2b51785 --- /dev/null +++ b/src/renderer/controllers/CatalogController.ts @@ -0,0 +1,59 @@ +import type { BridgeApi } from '../../shared/contracts/BridgeApi' +import type { CatalogListingDto } from '../../shared/contracts/dto/CatalogListingDto' +import type { AppStore } from '../state/AppStore' +import type { LogDrawerView } from '../views/LogDrawerView' + +/** + * The catalog operations, as the window performs them. + * + * Every one of them ends in a refresh: the engine is the authority on what is + * installed, so the window asks again rather than guessing what changed. + */ +export class CatalogController { + public constructor ( + private readonly bridge: BridgeApi, + private readonly store: AppStore, + private readonly log: LogDrawerView + ) {} + + public async refresh (): Promise { + try { + const listing: CatalogListingDto = await this.bridge.listGames() + this.store.applyCatalog(listing.games, listing.paths) + for (const reason of listing.skipped) this.log.appendLine(`skipped ${reason}`) + } catch (error: unknown) { + this.reportFailure(error) + } + } + + public async syncGames (names: readonly string[]): Promise { + try { + await this.bridge.syncGames(names) + } catch (error: unknown) { + this.reportFailure(error) + } + await this.refresh() + } + + public async removeGame (name: string): Promise { + try { + await this.bridge.removeGame(name) + } catch (error: unknown) { + this.reportFailure(error) + } + await this.refresh() + } + + public async launchGame (name: string): Promise { + try { + const launched = await this.bridge.launchGame(name) + if (!launched) this.log.appendLine(`${name}: ${this.store.readState().messages.failed}`) + } catch (error: unknown) { + this.reportFailure(error) + } + } + + private reportFailure (error: unknown): void { + this.log.appendLine(error instanceof Error ? error.message : String(error)) + } +} diff --git a/src/renderer/controllers/EngineStreamController.ts b/src/renderer/controllers/EngineStreamController.ts new file mode 100644 index 0000000..7d0db5b --- /dev/null +++ b/src/renderer/controllers/EngineStreamController.ts @@ -0,0 +1,67 @@ +import type { BridgeApi } from '../../shared/contracts/BridgeApi' +import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto' +import type { AppStore, SyncProgress } from '../state/AppStore' +import type { LogDrawerView } from '../views/LogDrawerView' + +/** + * The engine's own voice: its log, its progress events, and whether it is busy. + * + * Subscribed once at boot. The counter is derived from the events rather than + * guessed: `plan` says how many titles there are, `begin` and `installed` move it. + */ +export class EngineStreamController { + private progress: SyncProgress | null = null + + public constructor ( + private readonly bridge: BridgeApi, + private readonly store: AppStore, + private readonly log: LogDrawerView + ) {} + + public subscribe (): void { + this.bridge.onLog((line: string): void => { this.log.appendLine(line) }) + this.bridge.onBusyChanged((busy: boolean): void => { + if (!busy) this.progress = null + this.store.applyBusy(busy) + }) + this.bridge.onSyncEvent((event: SyncEventDto): void => { this.handleEvent(event) }) + } + + private handleEvent (event: SyncEventDto): void { + const messages = this.store.readState().messages + switch (event.event) { + case 'plan': + this.progress = { total: event.count, done: 0, label: `0 ${messages.of} ${String(event.count)}` } + this.store.applyProgress(this.progress) + return + case 'begin': { + const current = this.progress + if (current === null) return + this.progress = { + ...current, + label: `${String(current.done + 1)} ${messages.of} ${String(current.total)} · ${event.title}` + } + this.store.applyProgress(this.progress) + return + } + case 'installed': { + const current = this.progress + if (current !== null) { + this.progress = { ...current, done: current.done + 1 } + this.store.applyProgress(this.progress) + } + this.log.appendLine(`${event.title} — ${event.changed ? messages.installed : messages.upToDate}`) + return + } + case 'failed': + this.log.appendLine(`${event.name}: ${messages.failed} — ${event.error}`) + return + case 'removed': + this.log.appendLine(`${event.name} — ${messages.removed}`) + return + case 'pruned': + case 'finished': + return + } + } +} diff --git a/src/renderer/controllers/PreferencesController.ts b/src/renderer/controllers/PreferencesController.ts new file mode 100644 index 0000000..be2c702 --- /dev/null +++ b/src/renderer/controllers/PreferencesController.ts @@ -0,0 +1,21 @@ +import type { BridgeApi } from '../../shared/contracts/BridgeApi' +import type { AppStore } from '../state/AppStore' + +/** The two things the window remembers: its language and whether the menu is open. */ +export class PreferencesController { + public constructor ( + private readonly bridge: BridgeApi, + private readonly store: AppStore + ) {} + + public async selectLocale (candidate: string): Promise { + const selection = await this.bridge.updateLocale(candidate) + this.store.applyMessages(selection.locale, selection.messages) + } + + public async toggleNavigation (): Promise { + const open = !this.store.readState().navigationOpen + this.store.applyNavigationOpen(open) + await this.bridge.updateNavOpen(open) + } +} diff --git a/src/renderer/controllers/StoreController.ts b/src/renderer/controllers/StoreController.ts new file mode 100644 index 0000000..92f246c --- /dev/null +++ b/src/renderer/controllers/StoreController.ts @@ -0,0 +1,114 @@ +import type { BridgeApi } from '../../shared/contracts/BridgeApi' +import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto' +import type { AppStore } from '../state/AppStore' +import type { GateView } from '../views/GateView' +import type { LogDrawerView } from '../views/LogDrawerView' +import type { CatalogController } from './CatalogController' + +/** + * Which store the window drives, and how one gets onto the machine. + * + * The gate screens live here because they are all the same decision seen from + * different angles: there is no store to show a catalog for, and this is what can be + * done about it. + */ +export class StoreController { + public constructor ( + private readonly bridge: BridgeApi, + private readonly store: AppStore, + private readonly gate: GateView, + private readonly log: LogDrawerView, + private readonly catalog: CatalogController + ) {} + + /** Open another store that is already on this machine. */ + public async selectStore (home: string): Promise { + const messages = this.store.readState().messages + try { + const selection = await this.bridge.selectStore(home) + this.store.applySelectedStore(selection.store, selection.engine) + if (selection.engine !== null && !selection.engine.supported) { + this.showOutdatedEngineGate() + return + } + this.gate.hide() + await this.catalog.refresh() + } catch (error: unknown) { + this.log.appendLine(`${messages.switchFailed}: ${error instanceof Error ? error.message : String(error)}`) + } + } + + /** + * Which stores exist is the site's answer, not this client's: the registry is asked + * for it, and its records carry the catalog and the config repository. + */ + public async offerStores (): Promise { + const state = this.store.readState() + const messages = state.messages + const result = await this.bridge.listRegistryStores() + + if (result.error !== null) { + this.gate.show({ + title: messages.registryFailed, + body: `${result.sourceUrl}\n\n${result.error}`, + action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } } + }, messages) + return + } + + if (result.stores.length === 0) { + this.gate.show({ + title: messages.setupTitle, + body: `${messages.registryEmpty}\n\n${result.sourceUrl}` + }, messages) + return + } + + this.gate.show({ + title: messages.setupTitle, + body: `${messages.setupBody}\n\n${state.defaultStoreRoot}`, + action: { + label: messages.setupAction, + perform: (chosen: RegistryStoreDto | null): void => { + if (chosen !== null) void this.installStore(chosen) + } + }, + choices: result.stores + }, messages) + } + + public showOutdatedEngineGate (): void { + const state = this.store.readState() + const engineText = state.engine === null ? '' : state.engine.text + this.gate.show({ + title: state.messages.oldEngineTitle, + body: `${state.messages.oldEngineBody}\n\n${engineText} → ${state.minimumEngineVersion}`, + action: { label: state.messages.oldEngineAction, perform: (): void => { void this.offerStores() } } + }, state.messages) + } + + public showMissingPythonGate (): void { + const messages = this.store.readState().messages + this.gate.show({ + title: messages.noPythonTitle, + body: messages.noPythonBody, + link: { label: messages.pythonLink, url: 'https://www.python.org/downloads/' } + }, messages) + } + + private async installStore (chosen: RegistryStoreDto): Promise { + const messages = this.store.readState().messages + this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking }) + try { + await this.bridge.installStore(chosen) + this.store.applyAppState(await this.bridge.readState()) + this.gate.hide() + await this.catalog.refresh() + await this.catalog.syncGames([]) + } catch (error: unknown) { + this.log.appendLine(error instanceof Error ? error.message : String(error)) + } finally { + this.store.applyProgress(null) + } + } +} diff --git a/src/renderer/dom/Dom.ts b/src/renderer/dom/Dom.ts new file mode 100644 index 0000000..9edcc23 --- /dev/null +++ b/src/renderer/dom/Dom.ts @@ -0,0 +1,36 @@ +/** + * The DOM chores this window has, in one place. + * + * `requireElement` throws rather than returning null, and checks what it found + * against the element type asked for: every id it is called with is in index.html, so + * a missing or retyped one is a mistake in this repository and should say so loudly + * instead of silently rendering half a window. + */ +export function requireElement ( + id: string, + type: abstract new () => TElement +): TElement { + const found = document.getElementById(id) + if (found === null) throw new Error(`the element #${id} is missing from index.html`) + if (!(found instanceof type)) throw new Error(`#${id} is not a ${type.name}`) + return found +} + +export function createElement ( + tag: TTag, + className?: string, + text?: string +): HTMLElementTagNameMap[TTag] { + const node = document.createElement(tag) + if (className !== undefined) node.className = className + if (text !== undefined) node.textContent = text + return node +} + +export function setText (node: HTMLElement, value: string | number | null): void { + node.textContent = value === null ? '' : String(value) +} + +export function setHidden (node: HTMLElement, hidden: boolean): void { + node.hidden = hidden +} diff --git a/renderer/index.html b/src/renderer/index.html similarity index 100% rename from renderer/index.html rename to src/renderer/index.html diff --git a/src/renderer/main.ts b/src/renderer/main.ts new file mode 100644 index 0000000..f8d9c5a --- /dev/null +++ b/src/renderer/main.ts @@ -0,0 +1,7 @@ +import { RendererApplication } from './RendererApplication' + +// The renderer's entry point. Errors here would otherwise be invisible: the main +// process log stays empty when the page throws, so the window says it out loud. +void new RendererApplication().start().catch((error: unknown): void => { + console.error(error instanceof Error ? error.message : String(error)) +}) diff --git a/src/renderer/state/AppStore.ts b/src/renderer/state/AppStore.ts new file mode 100644 index 0000000..24ff04b --- /dev/null +++ b/src/renderer/state/AppStore.ts @@ -0,0 +1,133 @@ +import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto' +import type { EngineVersionDto } from '../../shared/contracts/dto/EngineVersionDto' +import type { GameDto } from '../../shared/contracts/dto/GameDto' +import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto' +import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto' +import { ENGLISH_MESSAGES } from '../../shared/i18n/EnglishMessages' +import type { Locale, MessageBundle } from '../../shared/i18n/MessageBundle' +import { ALL_CATEGORIES, type CategoryFilter } from './CategoryFilter' + +/** How far a running sync has got, for the counter in the bar. */ +export interface SyncProgress { + readonly total: number + readonly done: number + readonly label: string +} + +/** Everything the window draws itself from. Read-only to everyone but the store. */ +export interface AppState { + readonly locale: Locale + readonly locales: readonly Locale[] + readonly messages: MessageBundle + readonly navigationOpen: boolean + readonly pythonVersion: string | null + readonly stores: readonly InstalledStoreDto[] + readonly currentStore: InstalledStoreDto | null + readonly engine: EngineVersionDto | null + readonly minimumEngineVersion: string + readonly registryUrl: string + readonly defaultStoreRoot: string + readonly games: readonly GameDto[] + readonly paths: StorePathsDto | null + readonly filter: CategoryFilter + readonly busy: boolean + readonly progress: SyncProgress | null +} + +const INITIAL_STATE: AppState = { + locale: 'en', + locales: ['en'], + messages: ENGLISH_MESSAGES, + navigationOpen: true, + pythonVersion: null, + stores: [], + currentStore: null, + engine: null, + minimumEngineVersion: '', + registryUrl: '', + defaultStoreRoot: '', + games: [], + paths: null, + filter: ALL_CATEGORIES, + busy: false, + progress: null +} + +export type AppStateListener = (state: AppState) => void + +/** + * The window's single source of truth. + * + * Every mutator is named after what it changes, and every one of them notifies: the + * views re-render from the state rather than being poked individually, so there is no + * way to update the model and forget the screen. + */ +export class AppStore { + private state: AppState = INITIAL_STATE + private readonly listeners: AppStateListener[] = [] + + public readState (): AppState { + return this.state + } + + public subscribe (listener: AppStateListener): void { + this.listeners.push(listener) + } + + public applyAppState (dto: AppStateDto): void { + this.state = { + ...this.state, + locale: dto.locale, + locales: dto.locales, + messages: dto.messages, + navigationOpen: dto.navigationOpen, + pythonVersion: dto.pythonVersion, + stores: dto.stores, + currentStore: dto.currentStore, + engine: dto.engine, + minimumEngineVersion: dto.minimumEngineVersion, + registryUrl: dto.registryUrl, + defaultStoreRoot: dto.defaultStoreRoot + } + this.notify() + } + + public applyMessages (locale: Locale, messages: MessageBundle): void { + this.state = { ...this.state, locale, messages } + this.notify() + } + + public applyNavigationOpen (open: boolean): void { + this.state = { ...this.state, navigationOpen: open } + this.notify() + } + + public applyCatalog (games: readonly GameDto[], paths: StorePathsDto | null): void { + this.state = { ...this.state, games, paths: paths ?? this.state.paths } + this.notify() + } + + public applySelectedStore (store: InstalledStoreDto, engine: EngineVersionDto | null): void { + this.state = { ...this.state, currentStore: store, engine, games: [], paths: null, filter: ALL_CATEGORIES } + this.notify() + } + + public applyFilter (filter: CategoryFilter): void { + this.state = { ...this.state, filter } + this.notify() + } + + public applyBusy (busy: boolean): void { + this.state = { ...this.state, busy, progress: busy ? this.state.progress : null } + this.notify() + } + + public applyProgress (progress: SyncProgress | null): void { + this.state = { ...this.state, progress } + this.notify() + } + + private notify (): void { + for (const listener of this.listeners) listener(this.state) + } +} diff --git a/src/renderer/state/CategoryFilter.ts b/src/renderer/state/CategoryFilter.ts new file mode 100644 index 0000000..39c9178 --- /dev/null +++ b/src/renderer/state/CategoryFilter.ts @@ -0,0 +1,122 @@ +import type { GameDto } from '../../shared/contracts/dto/GameDto' +import type { MessageBundle } from '../../shared/i18n/MessageBundle' + +/** Which axis a category narrows the grid along. */ +export type CategoryKind = 'group' | 'platform' | 'mode' + +export interface CategoryFilter { + readonly kind: CategoryKind + readonly value: string +} + +export interface CategoryItem extends CategoryFilter { + readonly label: string + readonly count: number +} + +export interface CategorySection { + readonly title: string | null + readonly items: readonly CategoryItem[] +} + +export const ALL_CATEGORIES: CategoryFilter = { kind: 'group', value: 'all' } + +export function isSameFilter (left: CategoryFilter, right: CategoryFilter): boolean { + return left.kind === right.kind && left.value === right.value +} + +export function matchesFilter (game: GameDto, filter: CategoryFilter): boolean { + switch (filter.kind) { + case 'platform': + return game.platform === filter.value + case 'mode': + return game.mode === filter.value + case 'group': + return matchesGroup(game, filter.value) + } +} + +function matchesGroup (game: GameDto, group: string): boolean { + switch (group) { + case 'installed': + return game.installed + case 'updates': + return game.updateAvailable + case 'available': + return !game.installed + default: + return true + } +} + +/** + * The categories, built from what the catalog actually contains. + * + * There is no genre in a WarpEngine catalog, so the useful axes are the state of a + * title on this machine, the platform it was built with, and whether it runs here or + * in a browser. Empty axes are left out rather than shown as zeroes, and an axis with + * a single value is left out too — a filter that changes nothing is noise. + */ +export function buildCategorySections ( + games: readonly GameDto[], + messages: MessageBundle +): readonly CategorySection[] { + const count = (predicate: (game: GameDto) => boolean): number => games.filter(predicate).length + const sections: CategorySection[] = [] + + const groups: readonly CategoryItem[] = [ + { kind: 'group', value: 'all', label: messages.catAll, count: games.length }, + { kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) }, + { kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) }, + { kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => !game.installed) } + ] + sections.push({ + title: null, + items: groups.filter((item: CategoryItem): boolean => item.value === 'all' || item.count > 0) + }) + + const platforms = [...new Set(games.map((game: GameDto): string => game.platform))] + .filter((platform: string): boolean => platform.length > 0) + .sort((left: string, right: string): number => left.localeCompare(right)) + if (platforms.length > 1) { + sections.push({ + title: messages.catPlatform, + items: platforms.map((platform: string): CategoryItem => ({ + kind: 'platform', + value: platform, + label: platform, + count: count((game: GameDto): boolean => game.platform === platform) + })) + }) + } + + const modes = [...new Set(games.map((game: GameDto): string => game.mode))] + if (modes.length > 1) { + sections.push({ + title: messages.catMode, + items: modes.map((mode: string): CategoryItem => ({ + kind: 'mode', + value: mode, + label: mode === 'web' ? messages.hosted : messages.native, + count: count((game: GameDto): boolean => game.mode === mode) + })) + }) + } + + return sections +} + +/** + * A category can vanish under us — the last title of a platform is removed, or an + * update is applied — and a filter matching nothing would look like an empty + * catalog. Falling back to everything is the honest answer. + */ +export function resolveFilter ( + filter: CategoryFilter, + sections: readonly CategorySection[] +): CategoryFilter { + const known = sections + .flatMap((section: CategorySection): readonly CategoryItem[] => section.items) + .some((item: CategoryItem): boolean => isSameFilter(item, filter)) + return known ? filter : ALL_CATEGORIES +} diff --git a/renderer/style.css b/src/renderer/style.css similarity index 100% rename from renderer/style.css rename to src/renderer/style.css diff --git a/src/renderer/views/CatalogGridView.ts b/src/renderer/views/CatalogGridView.ts new file mode 100644 index 0000000..b4f5063 --- /dev/null +++ b/src/renderer/views/CatalogGridView.ts @@ -0,0 +1,28 @@ +import type { GameDto } from '../../shared/contracts/dto/GameDto' +import { requireElement, setHidden, setText } from '../dom/Dom' +import type { AppState } from '../state/AppStore' +import { matchesFilter } from '../state/CategoryFilter' +import type { GameCardView } from './GameCardView' + +/** The grid, and the sentence that stands in for it when there is nothing to show. */ +export class CatalogGridView { + private readonly grid = requireElement('grid', HTMLElement) + private readonly empty = requireElement('empty', HTMLElement) + + public constructor (private readonly cards: GameCardView) {} + + public render (state: AppState): void { + const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter)) + this.grid.replaceChildren(...shown.map((game: GameDto): HTMLElement => + this.cards.createCard(game, state.messages, state.busy))) + setHidden(this.grid, shown.length === 0) + this.grid.scrollTop = 0 + setHidden(this.empty, shown.length !== 0) + setText(this.empty, state.games.length === 0 ? state.messages.noGames : state.messages.noMatch) + } + + public hide (): void { + setHidden(this.grid, true) + setHidden(this.empty, true) + } +} diff --git a/src/renderer/views/GameCardView.ts b/src/renderer/views/GameCardView.ts new file mode 100644 index 0000000..e5ccd33 --- /dev/null +++ b/src/renderer/views/GameCardView.ts @@ -0,0 +1,92 @@ +import type { GameDto } from '../../shared/contracts/dto/GameDto' +import type { MessageBundle } from '../../shared/i18n/MessageBundle' +import { createElement } from '../dom/Dom' + +export interface GameCardViewCallbacks { + readonly onInstall: (name: string) => void + readonly onLaunch: (name: string) => void + readonly onRemove: (name: string) => void +} + +/** + * One card. + * + * A card is a function of a title and the strings: it holds no state of its own, so + * the grid can throw the lot away and rebuild after every listing. + */ +export class GameCardView { + public constructor (private readonly callbacks: GameCardViewCallbacks) {} + + public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement { + const card = createElement('article', 'card') + if (game.installed) card.classList.add('is-installed') + card.appendChild(this.createArt(game)) + card.appendChild(this.createBody(game, messages, busy)) + return card + } + + private createArt (game: GameDto): HTMLElement { + const art = createElement('div', 'art') + if (game.imageUrl !== null) { + const image = createElement('img') + image.src = game.imageUrl + image.alt = '' + image.loading = 'lazy' + art.appendChild(image) + return art + } + // No box art in the catalog: the first letter, on the same band an image would + // fill, so a row of cards stays aligned either way. + art.appendChild(createElement('span', 'art-glyph', game.title.slice(0, 1).toUpperCase())) + return art + } + + private createBody (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement { + const body = createElement('div', 'body') + body.appendChild(createElement('h2', undefined, game.title)) + body.appendChild(this.createMeta(game, messages)) + if (game.description.length > 0) { + body.appendChild(createElement('p', 'desc', game.description)) + } + body.appendChild(this.createActions(game, messages, busy)) + return body + } + + private createMeta (game: GameDto, messages: MessageBundle): HTMLElement { + const meta = createElement('div', 'meta') + const mode = createElement('span', `badge badge-${game.mode}`, + game.mode === 'web' ? messages.hosted : messages.native) + mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint + meta.appendChild(mode) + meta.appendChild(createElement('span', 'badge badge-plain', game.platform)) + meta.appendChild(createElement('span', 'version', + game.installed && game.installedVersion !== null + ? `${game.installedVersion} · ${messages.installed}` + : game.version)) + return meta + } + + private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement { + const actions = createElement('div', 'actions') + const primary = createElement('button', 'btn btn-primary') + primary.disabled = busy + + if (game.installed && !game.updateAvailable) { + primary.textContent = game.mode === 'web' ? messages.open : messages.play + primary.disabled = busy || !game.launchable + primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) }) + } else { + primary.textContent = game.updateAvailable ? messages.update : messages.install + primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) }) + } + actions.appendChild(primary) + + if (game.installed) { + const remove = createElement('button', 'btn btn-ghost', messages.remove) + remove.disabled = busy + remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) }) + actions.appendChild(remove) + } + return actions + } +} diff --git a/src/renderer/views/GateView.ts b/src/renderer/views/GateView.ts new file mode 100644 index 0000000..b2ee8b3 --- /dev/null +++ b/src/renderer/views/GateView.ts @@ -0,0 +1,91 @@ +import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto' +import { createElement, requireElement, setHidden, setText } from '../dom/Dom' +import type { MessageBundle } from '../../shared/i18n/MessageBundle' + +/** A button on the gate, and what choosing it does. */ +export interface GateAction { + readonly label: string + readonly perform: (chosen: RegistryStoreDto | null) => void +} + +export interface GateLink { + readonly label: string + readonly url: string +} + +export interface GatePresentation { + readonly title: string + readonly body: string + readonly action?: GateAction + readonly link?: GateLink + readonly choices?: readonly RegistryStoreDto[] +} + +/** + * The screen shown instead of the grid when there is nothing to drive: no Python, no + * store yet, an engine too old, or a registry that cannot be reached. + */ +export class GateView { + private readonly section = requireElement('gate', HTMLElement) + private readonly title = requireElement('gate-title', HTMLElement) + private readonly body = requireElement('gate-body', HTMLElement) + private readonly choice = requireElement('gate-choice', HTMLElement) + private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement) + private readonly select = requireElement('gate-select', HTMLSelectElement) + private readonly button = requireElement('gate-action', HTMLButtonElement) + private readonly link = requireElement('gate-link', HTMLAnchorElement) + + public constructor (private readonly onOpenUrl: (url: string) => void) {} + + public show (presentation: GatePresentation, messages: MessageBundle): void { + setHidden(this.section, false) + setText(this.title, presentation.title) + setText(this.body, presentation.body) + this.renderChoices(presentation.choices ?? [], messages) + this.renderAction(presentation) + this.renderLink(presentation.link ?? null) + } + + public hide (): void { + setHidden(this.section, true) + } + + public get visible (): boolean { + return !this.section.hidden + } + + /** Only shown when the registry offers more than one store; with a single one there is nothing to decide. */ + private renderChoices (choices: readonly RegistryStoreDto[], messages: MessageBundle): void { + setHidden(this.choice, choices.length < 2) + if (choices.length < 2) return + setText(this.choiceLabel, messages.setupChoose) + this.select.replaceChildren(...choices.map((store: RegistryStoreDto, index: number): HTMLOptionElement => { + const option = createElement('option', undefined, store.name) + option.value = String(index) + return option + })) + } + + private renderAction (presentation: GatePresentation): void { + const action = presentation.action + setHidden(this.button, action === undefined) + this.button.disabled = false + if (action === undefined) return + setText(this.button, action.label) + this.button.onclick = (): void => { + const choices = presentation.choices ?? [] + const index = Number(this.select.value) + action.perform(choices[Number.isFinite(index) ? index : 0] ?? choices[0] ?? null) + } + } + + private renderLink (link: GateLink | null): void { + setHidden(this.link, link === null) + if (link === null) return + setText(this.link, link.label) + this.link.onclick = (event: MouseEvent): void => { + event.preventDefault() + this.onOpenUrl(link.url) + } + } +} diff --git a/src/renderer/views/LogDrawerView.ts b/src/renderer/views/LogDrawerView.ts new file mode 100644 index 0000000..3ea75ad --- /dev/null +++ b/src/renderer/views/LogDrawerView.ts @@ -0,0 +1,52 @@ +import { createElement, requireElement, setText } from '../dom/Dom' +import type { AppState } from '../state/AppStore' + +const MAX_LOG_LINES = 400 + +export interface LogDrawerViewCallbacks { + readonly onOpenFolder: (directory: string) => void +} + +/** The store's own output, verbatim, and the folders everything lands in. */ +export class LogDrawerView { + private readonly toggle = requireElement('log-toggle', HTMLButtonElement) + private readonly lines = requireElement('log-lines', HTMLElement) + private readonly pathsBox = requireElement('log-paths', HTMLElement) + + public constructor (private readonly callbacks: LogDrawerViewCallbacks) { + this.toggle.addEventListener('click', (): void => { + this.lines.hidden = !this.lines.hidden + this.toggle.setAttribute('aria-expanded', String(!this.lines.hidden)) + }) + } + + public render (state: AppState): void { + setText(this.toggle, state.messages.log) + this.pathsBox.replaceChildren() + const paths = state.paths + if (paths === null) return + + this.pathsBox.appendChild(createElement('div', 'paths-line', + `${state.messages.paths}: ${paths.storeFolder} · ${paths.menuGroup}`)) + + const folders: readonly (readonly [string, string])[] = [ + [state.messages.openStoreFolder, paths.storeFolder], + [state.messages.openMenuFolder, paths.menuGroup] + ] + for (const [label, directory] of folders) { + const button = createElement('button', 'btn btn-tiny', label) + button.addEventListener('click', (): void => { this.callbacks.onOpenFolder(directory) }) + this.pathsBox.appendChild(button) + } + } + + public appendLine (line: string): void { + this.lines.appendChild(createElement('div', 'log-line', line)) + while (this.lines.childElementCount > MAX_LOG_LINES) { + const first = this.lines.firstElementChild + if (first === null) break + first.remove() + } + this.lines.scrollTop = this.lines.scrollHeight + } +} diff --git a/src/renderer/views/SideMenuView.ts b/src/renderer/views/SideMenuView.ts new file mode 100644 index 0000000..cb0ced8 --- /dev/null +++ b/src/renderer/views/SideMenuView.ts @@ -0,0 +1,124 @@ +import { createElement, requireElement, setText } from '../dom/Dom' +import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto' +import type { Locale } from '../../shared/i18n/MessageBundle' +import { + buildCategorySections, isSameFilter, type CategoryFilter, type CategoryItem +} from '../state/CategoryFilter' +import type { AppState } from '../state/AppStore' + +export interface SideMenuViewCallbacks { + readonly onSelectStore: (home: string) => void + readonly onAddStore: () => void + readonly onSyncAll: () => void + readonly onRefresh: () => void + readonly onSelectCategory: (filter: CategoryFilter) => void + readonly onSelectLocale: (locale: string) => void +} + +/** + * The side menu: which store, what to do with it, and what to look at. + * + * Everything that is not a title lives here, and the bar's button folds it away. + */ +export class SideMenuView { + private readonly storesHead = requireElement('head-stores', HTMLElement) + private readonly actionsHead = requireElement('head-actions', HTMLElement) + private readonly categoriesHead = requireElement('head-cats', HTMLElement) + private readonly languageHead = requireElement('head-lang', HTMLElement) + private readonly storeList = requireElement('store-list', HTMLElement) + private readonly addStore = requireElement('add-store', HTMLButtonElement) + private readonly syncAll = requireElement('sync-all', HTMLButtonElement) + private readonly refresh = requireElement('refresh', HTMLButtonElement) + private readonly categories = requireElement('cats', HTMLElement) + private readonly locale = requireElement('locale', HTMLSelectElement) + + public constructor (private readonly callbacks: SideMenuViewCallbacks) { + this.addStore.addEventListener('click', callbacks.onAddStore) + this.syncAll.addEventListener('click', callbacks.onSyncAll) + this.refresh.addEventListener('click', callbacks.onRefresh) + this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) }) + } + + public render (state: AppState): void { + setText(this.storesHead, state.messages.stores) + setText(this.actionsHead, state.messages.actions) + setText(this.categoriesHead, state.messages.categories) + setText(this.languageHead, state.messages.language) + setText(this.addStore, state.messages.addStore) + setText(this.syncAll, state.messages.syncAll) + setText(this.refresh, state.messages.refresh) + + this.renderStores(state) + this.renderCategories(state) + this.renderLocales(state) + this.renderEnabled(state) + } + + private renderStores (state: AppState): void { + const activeHome = state.currentStore === null ? null : state.currentStore.home + // Two stores can carry the same id in different roots — the same catalog + // installed twice. Then the id says nothing and the folder is what tells them + // apart, so that is what the row shows. + const ambiguousIds = new Set(state.stores + .filter((store: InstalledStoreDto, index: number): boolean => + state.stores.findIndex((other: InstalledStoreDto): boolean => other.id === store.id) !== index) + .map((store: InstalledStoreDto): string => store.id)) + + this.storeList.replaceChildren(...state.stores.map((store: InstalledStoreDto): HTMLElement => { + const row = createElement('button', 'store-row') + if (store.home === activeHome) row.classList.add('is-active') + row.appendChild(createElement('span', 'store-row-name', store.name)) + row.appendChild(createElement('span', 'store-row-id', + ambiguousIds.has(store.id) ? store.home : store.id)) + row.title = store.home + row.addEventListener('click', (): void => { + if (store.home !== activeHome) this.callbacks.onSelectStore(store.home) + }) + return row + })) + } + + private renderCategories (state: AppState): void { + const sections = buildCategorySections(state.games, state.messages) + const nodes: HTMLElement[] = [] + for (const section of sections) { + if (section.title !== null) nodes.push(createElement('div', 'cat-group', section.title)) + for (const item of section.items) nodes.push(this.createCategoryRow(item, state.filter)) + } + this.categories.replaceChildren(...nodes) + } + + private createCategoryRow (item: CategoryItem, active: CategoryFilter): HTMLElement { + const row = createElement('button', 'cat') + if (isSameFilter(item, active)) row.classList.add('is-active') + row.appendChild(createElement('span', 'cat-label', item.label)) + row.appendChild(createElement('span', 'cat-count', String(item.count))) + row.addEventListener('click', (): void => { + this.callbacks.onSelectCategory({ kind: item.kind, value: item.value }) + }) + return row + } + + private renderLocales (state: AppState): void { + const current = this.locale.value + if (current === state.locale && this.locale.options.length === state.locales.length) return + this.locale.replaceChildren(...state.locales.map((locale: Locale): HTMLOptionElement => { + const option = createElement('option', undefined, locale.toUpperCase()) + option.value = locale + option.selected = locale === state.locale + return option + })) + } + + /** + * While the store is working only what would start a second call is disabled; the + * filters stay live because they change what is on screen and nothing on disk. + */ + private renderEnabled (state: AppState): void { + const hasStore = state.currentStore !== null + this.syncAll.disabled = state.busy || !hasStore + this.refresh.disabled = state.busy || !hasStore + this.addStore.disabled = state.busy + for (const row of this.storeList.querySelectorAll('button')) row.disabled = state.busy + } +} diff --git a/src/renderer/views/TopBarView.ts b/src/renderer/views/TopBarView.ts new file mode 100644 index 0000000..f7d9dc5 --- /dev/null +++ b/src/renderer/views/TopBarView.ts @@ -0,0 +1,30 @@ +import { requireElement, setHidden, setText } from '../dom/Dom' +import type { AppState } from '../state/AppStore' + +export interface TopBarViewCallbacks { + readonly onToggleNavigation: () => void +} + +/** The bar: what this is, which store is open, and how far a sync has got. */ +export class TopBarView { + private readonly appName = requireElement('app-name', HTMLElement) + private readonly storeId = requireElement('store-id', HTMLElement) + private readonly progress = requireElement('progress', HTMLElement) + private readonly navToggle = requireElement('nav-toggle', HTMLButtonElement) + + public constructor (callbacks: TopBarViewCallbacks) { + this.navToggle.addEventListener('click', callbacks.onToggleNavigation) + } + + public render (state: AppState): void { + setText(this.appName, state.messages.appName) + setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id) + this.navToggle.title = state.messages.menu + this.navToggle.setAttribute('aria-label', state.messages.menu) + this.navToggle.setAttribute('aria-expanded', String(state.navigationOpen)) + + const progress = state.progress + setHidden(this.progress, progress === null) + setText(this.progress, progress === null ? '' : progress.label) + } +} diff --git a/src/scripts/SmokeTest.ts b/src/scripts/SmokeTest.ts new file mode 100644 index 0000000..21cbef8 --- /dev/null +++ b/src/scripts/SmokeTest.ts @@ -0,0 +1,209 @@ +import fs from 'node:fs' +import path from 'node:path' +import { GameDtoMapper } from '../application/mappers/GameDtoMapper' +import type { CatalogListing } from '../domain/models/CatalogListing' +import type { InstalledStore } from '../domain/models/InstalledStore' +import type { RegistryStore } from '../domain/models/RegistryStore' +import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine' +import { deriveStoreId } from '../domain/models/StoreIdentity' +import { HttpTextClient } from '../infrastructure/http/HttpTextClient' +import { PythonEngineProcessRunner } from '../infrastructure/process/PythonEngineProcessRunner' +import { SystemPythonRuntimeLocator } from '../infrastructure/process/SystemPythonRuntimeLocator' +import { FileSystemInstalledStoreRepository } from '../infrastructure/repositories/FileSystemInstalledStoreRepository' +import { HttpStoreRegistryRepository } from '../infrastructure/repositories/HttpStoreRegistryRepository' +import { PythonStoreCatalogGateway } from '../infrastructure/repositories/PythonStoreCatalogGateway' +import type { GameDto } from '../shared/contracts/dto/GameDto' +import { ENGLISH_MESSAGES } from '../shared/i18n/EnglishMessages' +import { HUNGARIAN_MESSAGES } from '../shared/i18n/HungarianMessages' +import { LOCALES } from '../shared/i18n/MessageBundle' + +/** + * Drives the store with no window and no Electron at all. + * + * This is the second composition root, and the reason the layers are worth having: + * the same services the window uses are assembled here against the same ports, so an + * integration mistake shows up in a terminal rather than in a screenshot. + * + * npm run smoke the store on this machine + * SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store + */ +class SmokeTest { + private failed = false + + private readonly stores = new FileSystemInstalledStoreRepository() + private readonly pythonLocator = new SystemPythonRuntimeLocator() + private readonly catalogGateway = new PythonStoreCatalogGateway( + new PythonEngineProcessRunner(this.pythonLocator) + ) + private readonly httpClient = new HttpTextClient() + private readonly registry = new HttpStoreRegistryRepository(this.httpClient) + private readonly gameMapper = new GameDtoMapper() + + public async run (): Promise { + console.log('warp-engine-desktop-gui smoke test') + + if (!this.checkPython()) return 1 + this.checkMessages() + await this.checkRegistry() + + const store = this.findStore() + if (store === null) return this.failed ? 1 : 0 + await this.checkCatalog(store) + + return this.failed ? 1 : 0 + } + + private checkPython (): boolean { + const runtime = this.pythonLocator.findRuntime() + if (runtime === null) { + this.reportBad('python', 'not found — the store cannot run') + return false + } + this.reportOk('python', runtime.version) + return true + } + + /** The bundles are typed against one key set, so this only counts them. */ + private checkMessages (): void { + const english = Object.keys(ENGLISH_MESSAGES).length + const hungarian = Object.keys(HUNGARIAN_MESSAGES).length + if (english === hungarian) this.reportOk('strings', `${String(english)} keys × ${String(LOCALES.length)} languages`) + else this.reportBad('strings', `en has ${String(english)}, hu has ${String(hungarian)}`) + } + + private async checkRegistry (): Promise { + try { + const stores = await this.registry.listStores() + if (stores.length === 0) { + this.reportBad('registry', `${this.registry.sourceUrl} returned no stores`) + return + } + this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`) + for (const store of stores) { + this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`) + await this.checkStoreConfig(store) + } + } catch (error: unknown) { + this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`) + } + } + + /** + * A store repository without a config.json still installs — the engine merges what + * it is given onto its defaults — so an absent file is reported, not failed. + */ + private async checkStoreConfig (store: RegistryStore): Promise { + const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json` + try { + const config: unknown = JSON.parse(await this.httpClient.readText(url)) + const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0 + this.reportOk(' config.json', `${String(sections)} sections`) + } catch (error: unknown) { + this.reportOk(' config.json', `absent (${this.describe(error)}) — defaults would be used`) + } + } + + private findStore (): InstalledStore | null { + const sandbox = process.env['SMOKE_HOME'] + if (sandbox !== undefined && sandbox.length > 0) { + const home = path.resolve(sandbox) + const store: InstalledStore = { + id: path.basename(home).replace(DESKTOP_STORE_ENGINE.homeSuffix, ''), + name: path.basename(home), + home, + scriptPath: path.join(home, DESKTOP_STORE_ENGINE.scriptFileName), + configPath: path.join(home, 'config.json'), + engine: DESKTOP_STORE_ENGINE.id + } + for (const file of [store.scriptPath, store.configPath]) { + if (!fs.existsSync(file)) { + this.reportBad('SMOKE_HOME', `${file} is missing`) + return null + } + } + this.reportOk('store (SMOKE_HOME)', store.home) + return store + } + + const found = this.stores.findAll()[0] + if (found === undefined) { + console.log(' skip no store installed — run the app once, or set SMOKE_HOME') + console.log(` it would be installed in ${this.stores.resolveDefaultHome('ttg')}`) + return null + } + this.reportOk('store found', `${found.id} in ${found.home}`) + return found + } + + private async checkCatalog (store: InstalledStore): Promise { + const logLines: string[] = [] + const progress = { onLog: (line: string): void => { logLines.push(line) } } + + const engine = this.catalogGateway.readEngineVersion(store) + if (engine === null) this.reportBad('engine', 'the store did not answer --version') + else if (!engine.supported) this.reportBad('engine', `${engine.text} is too old for this client`) + else this.reportOk('engine', engine.text) + + const paths = await this.catalogGateway.readPaths(store, progress) + if (paths.operatingSystem.length > 0 && paths.storeFolder.length > 0) { + this.reportOk('paths', `${paths.operatingSystem} → ${paths.storeFolder}`) + } else { + this.reportBad('paths', JSON.stringify(paths)) + } + + const listing: CatalogListing = await this.catalogGateway.listGames(store, progress) + if (listing.games.length === 0) { + this.reportBad('list', 'no games came back') + return + } + const games = this.gameMapper.toDtoList(listing.games, listing.paths?.catalogBaseUrl ?? '') + this.reportListing(games) + + if (logLines.length > 0) this.reportOk('stderr log', `${String(logLines.length)} lines (kept off stdout)`) + } + + private reportListing (games: readonly GameDto[]): void { + const native = games.filter((game: GameDto): boolean => game.mode === 'app').length + const hosted = games.length - native + this.reportOk('list', `${String(games.length)} titles (app:${String(native)}, web:${String(hosted)})`) + + const withoutTitle = games.filter((game: GameDto): boolean => + game.name.length === 0 || game.title.length === 0 || game.platform.length === 0) + if (withoutTitle.length > 0) this.reportBad('game shape', `${String(withoutTitle.length)} entries are incomplete`) + else this.reportOk('game shape', 'name, title, platform, version, mode, installed, updateAvailable') + + const installed = games.filter((game: GameDto): boolean => game.installed) + this.reportOk('installed', `${String(installed.length)} of ${String(games.length)}`) + + const unlaunchable = installed.filter((game: GameDto): boolean => !game.launchable) + if (unlaunchable.length > 0) this.reportBad('launch targets', `${String(unlaunchable.length)} installed titles have nothing to launch`) + else if (installed.length > 0) this.reportOk('launch targets', 'every installed title has one') + + const withArt = games.filter((game: GameDto): boolean => game.imageUrl !== null) + if (withArt.length > 0) { + const first = withArt[0] + if (first !== undefined) this.reportOk('box art', first.imageUrl ?? '') + } + } + + private describe (error: unknown): string { + return error instanceof Error ? error.message : String(error) + } + + private reportOk (label: string, value?: string): void { + console.log(` ok ${label}${value === undefined ? '' : `: ${value}`}`) + } + + private reportBad (label: string, value: string): void { + this.failed = true + console.log(` FAIL ${label}: ${value}`) + } +} + +void new SmokeTest().run().then( + (code: number): void => { process.exitCode = code }, + (error: unknown): void => { + console.log(` FAIL error: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } +) diff --git a/src/shared/contracts/BridgeApi.ts b/src/shared/contracts/BridgeApi.ts new file mode 100644 index 0000000..13219bc --- /dev/null +++ b/src/shared/contracts/BridgeApi.ts @@ -0,0 +1,46 @@ +import type { AppStateDto } from './dto/AppStateDto' +import type { CatalogListingDto } from './dto/CatalogListingDto' +import type { InstalledStoreDto } from './dto/InstalledStoreDto' +import type { LocaleSelectionDto } from './dto/LocaleSelectionDto' +import type { RegistryResultDto } from './dto/RegistryResultDto' +import type { RegistryStoreDto } from './dto/RegistryStoreDto' +import type { StorePathsDto } from './dto/StorePathsDto' +import type { StoreSelectionDto } from './dto/StoreSelectionDto' +import type { SyncEventDto } from './dto/SyncEventDto' + +/** A stream the main process pushes; the window subscribes and never replies. */ +export type StreamListener = (payload: TPayload) => void + +/** + * The entire surface the window gets. + * + * Nothing else reaches it: no Node, no filesystem, no child processes. Both sides + * compile against this interface, so a method the preload does not expose is a + * compile error in the renderer rather than an `undefined is not a function` at + * runtime. + */ +export interface BridgeApi { + readState: () => Promise + updateLocale: (locale: string) => Promise + updateNavOpen: (open: boolean) => Promise + + listGames: () => Promise + readPaths: () => Promise + syncGames: (names: readonly string[]) => Promise + removeGame: (name: string) => Promise + launchGame: (name: string) => Promise + + listRegistryStores: () => Promise + installStore: (store: RegistryStoreDto) => Promise + selectStore: (home: string) => Promise + + openFolder: (directory: string) => Promise + openUrl: (url: string) => Promise + + onLog: (listener: StreamListener) => void + onSyncEvent: (listener: StreamListener) => void + onBusyChanged: (listener: StreamListener) => void +} + +/** The name the bridge is published under on `window`. */ +export const BRIDGE_GLOBAL_NAME = 'storeApi' diff --git a/src/shared/contracts/IpcChannels.ts b/src/shared/contracts/IpcChannels.ts new file mode 100644 index 0000000..f6e7e1f --- /dev/null +++ b/src/shared/contracts/IpcChannels.ts @@ -0,0 +1,32 @@ +/** + * Every channel name, in one frozen table. + * + * The pattern is `:` — the same verbs the services use, so + * a channel, the bridge method it backs and the service call behind it read as one + * sentence. Renderer and main both import this table; a typo cannot make a channel + * that only one side knows about. + */ +export const IPC_CHANNELS = { + appReadState: 'app:readState', + appUpdateLocale: 'app:updateLocale', + appUpdateNavOpen: 'app:updateNavOpen', + appOpenFolder: 'app:openFolder', + appOpenUrl: 'app:openUrl', + + catalogListGames: 'catalog:listGames', + catalogReadPaths: 'catalog:readPaths', + catalogSyncGames: 'catalog:syncGames', + catalogRemoveGame: 'catalog:removeGame', + catalogLaunchGame: 'catalog:launchGame', + + storeListRegistry: 'store:listRegistry', + storeInstallStore: 'store:installStore', + storeSelectStore: 'store:selectStore', + + /** Main to renderer, one way. */ + streamLog: 'stream:log', + streamSyncEvent: 'stream:syncEvent', + streamBusyChanged: 'stream:busyChanged' +} as const + +export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS] diff --git a/src/shared/contracts/dto/AppStateDto.ts b/src/shared/contracts/dto/AppStateDto.ts new file mode 100644 index 0000000..82ae727 --- /dev/null +++ b/src/shared/contracts/dto/AppStateDto.ts @@ -0,0 +1,21 @@ +import type { Locale } from '../../i18n/MessageBundle' +import type { MessageBundle } from '../../i18n/MessageBundle' +import type { EngineVersionDto } from './EngineVersionDto' +import type { InstalledStoreDto } from './InstalledStoreDto' + +/** Everything the window needs before it can paint anything. */ +export interface AppStateDto { + readonly locale: Locale + readonly locales: readonly Locale[] + readonly messages: MessageBundle + readonly navigationOpen: boolean + /** The Python 3 version string, or null when there is none to drive the store. */ + readonly pythonVersion: string | null + readonly currentStore: InstalledStoreDto | null + readonly stores: readonly InstalledStoreDto[] + readonly engine: EngineVersionDto | null + readonly minimumEngineVersion: string + readonly registryUrl: string + readonly defaultStoreRoot: string + readonly appVersion: string +} diff --git a/src/shared/contracts/dto/CatalogListingDto.ts b/src/shared/contracts/dto/CatalogListingDto.ts new file mode 100644 index 0000000..870d490 --- /dev/null +++ b/src/shared/contracts/dto/CatalogListingDto.ts @@ -0,0 +1,9 @@ +import type { GameDto } from './GameDto' +import type { StorePathsDto } from './StorePathsDto' + +/** One `listGames` answer: what the catalog offers, and what was left out. */ +export interface CatalogListingDto { + readonly games: readonly GameDto[] + readonly skipped: readonly string[] + readonly paths: StorePathsDto | null +} diff --git a/src/shared/contracts/dto/EngineVersionDto.ts b/src/shared/contracts/dto/EngineVersionDto.ts new file mode 100644 index 0000000..75dd2e7 --- /dev/null +++ b/src/shared/contracts/dto/EngineVersionDto.ts @@ -0,0 +1,5 @@ +/** The installed engine's version, and whether this client can drive it. */ +export interface EngineVersionDto { + readonly text: string + readonly supported: boolean +} diff --git a/src/shared/contracts/dto/GameDto.ts b/src/shared/contracts/dto/GameDto.ts new file mode 100644 index 0000000..5465318 --- /dev/null +++ b/src/shared/contracts/dto/GameDto.ts @@ -0,0 +1,26 @@ +/** How a title runs: unpacked on this machine, or served as a web build. */ +export type GameModeDto = 'app' | 'web' + +/** + * A catalog entry as the window needs it. + * + * Deliberately free of filesystem paths: what to launch is resolved in the main + * process from the store's own state, so the window never learns where anything + * lives and cannot be talked into opening it. + */ +export interface GameDto { + readonly name: string + readonly title: string + readonly platform: string + readonly version: string + readonly mode: GameModeDto + readonly kind: string + readonly description: string + readonly author: string + /** Absolute, already resolved against the catalog's base URL. */ + readonly imageUrl: string | null + readonly installed: boolean + readonly updateAvailable: boolean + readonly installedVersion: string | null + readonly launchable: boolean +} diff --git a/src/shared/contracts/dto/InstalledStoreDto.ts b/src/shared/contracts/dto/InstalledStoreDto.ts new file mode 100644 index 0000000..afb242f --- /dev/null +++ b/src/shared/contracts/dto/InstalledStoreDto.ts @@ -0,0 +1,7 @@ +/** A store on this machine, as the switcher lists it. */ +export interface InstalledStoreDto { + readonly id: string + readonly name: string + readonly home: string + readonly engine: string +} diff --git a/src/shared/contracts/dto/LocaleSelectionDto.ts b/src/shared/contracts/dto/LocaleSelectionDto.ts new file mode 100644 index 0000000..83c47cc --- /dev/null +++ b/src/shared/contracts/dto/LocaleSelectionDto.ts @@ -0,0 +1,7 @@ +import type { Locale, MessageBundle } from '../../i18n/MessageBundle' + +/** The answer to a language change: what was stored, and the strings for it. */ +export interface LocaleSelectionDto { + readonly locale: Locale + readonly messages: MessageBundle +} diff --git a/src/shared/contracts/dto/RegistryResultDto.ts b/src/shared/contracts/dto/RegistryResultDto.ts new file mode 100644 index 0000000..fdebc1f --- /dev/null +++ b/src/shared/contracts/dto/RegistryResultDto.ts @@ -0,0 +1,14 @@ +import type { RegistryStoreDto } from './RegistryStoreDto' + +/** + * The registry lookup, failure included. + * + * The window has to say *why* there is nothing to install — an unreachable site + * and an empty list need different words — so the error travels as data rather + * than as a rejected promise. + */ +export interface RegistryResultDto { + readonly stores: readonly RegistryStoreDto[] + readonly sourceUrl: string + readonly error: string | null +} diff --git a/src/shared/contracts/dto/RegistryStoreDto.ts b/src/shared/contracts/dto/RegistryStoreDto.ts new file mode 100644 index 0000000..4887535 --- /dev/null +++ b/src/shared/contracts/dto/RegistryStoreDto.ts @@ -0,0 +1,8 @@ +/** A store the registry offers, before anything is installed. */ +export interface RegistryStoreDto { + readonly name: string + readonly catalogUrl: string + readonly storeRepositoryUrl: string + /** Derived from the repository name, so the picker can show what it will become. */ + readonly storeId: string +} diff --git a/src/shared/contracts/dto/StorePathsDto.ts b/src/shared/contracts/dto/StorePathsDto.ts new file mode 100644 index 0000000..8f4b700 --- /dev/null +++ b/src/shared/contracts/dto/StorePathsDto.ts @@ -0,0 +1,10 @@ +/** Where a store puts things on this machine, as the log drawer reports it. */ +export interface StorePathsDto { + readonly operatingSystem: string + readonly architecture: string + readonly storeFolder: string + readonly menuGroup: string + readonly catalogBaseUrl: string + readonly storeName: string + readonly storeId: string +} diff --git a/src/shared/contracts/dto/StoreSelectionDto.ts b/src/shared/contracts/dto/StoreSelectionDto.ts new file mode 100644 index 0000000..c66ea38 --- /dev/null +++ b/src/shared/contracts/dto/StoreSelectionDto.ts @@ -0,0 +1,8 @@ +import type { EngineVersionDto } from './EngineVersionDto' +import type { InstalledStoreDto } from './InstalledStoreDto' + +/** The answer to a store switch: which store is open, and can it be driven. */ +export interface StoreSelectionDto { + readonly store: InstalledStoreDto + readonly engine: EngineVersionDto | null +} diff --git a/src/shared/contracts/dto/SyncEventDto.ts b/src/shared/contracts/dto/SyncEventDto.ts new file mode 100644 index 0000000..c5adab1 --- /dev/null +++ b/src/shared/contracts/dto/SyncEventDto.ts @@ -0,0 +1,15 @@ +/** + * One line of the engine's progress stream. + * + * The engine emits JSONL on stdout; these are the events the window reacts to. + * `plan` gives the count, `begin`/`installed` drive the counter, and the rest end + * up in the log drawer. + */ +export type SyncEventDto = + | { readonly event: 'plan'; readonly count: number } + | { readonly event: 'begin'; readonly name: string; readonly title: string } + | { readonly event: 'installed'; readonly name: string; readonly title: string; readonly changed: boolean } + | { readonly event: 'failed'; readonly name: string; readonly error: string } + | { readonly event: 'removed'; readonly name: string } + | { readonly event: 'pruned'; readonly name: string } + | { readonly event: 'finished'; readonly installed: number; readonly failed: number } diff --git a/src/shared/i18n/EnglishMessages.ts b/src/shared/i18n/EnglishMessages.ts new file mode 100644 index 0000000..abb292a --- /dev/null +++ b/src/shared/i18n/EnglishMessages.ts @@ -0,0 +1,63 @@ +/** + * The English message bundle, and the source of the key set. + * + * Every other language is typed against these keys, so a missing or misspelled + * translation is a compile error rather than a blank label at runtime. + */ +export const ENGLISH_MESSAGES = { + appName: 'WarpEngine Store', + syncAll: 'Install all', + refresh: 'Refresh', + install: 'Install', + update: 'Update', + play: 'Play', + open: 'Open', + remove: 'Remove', + installed: 'installed', + native: 'native', + hosted: 'hosted', + hostedHint: 'Opens in your browser — needs the network', + nativeHint: 'Installed on this machine — works offline', + updateAvailable: 'update available', + log: 'Log', + menu: 'Menu', + stores: 'Stores', + addStore: 'Add a store…', + switchFailed: 'That store could not be opened', + actions: 'Actions', + categories: 'Categories', + catAll: 'Everything', + catInstalled: 'Installed', + catUpdates: 'Updates', + catAvailable: 'Not installed', + catPlatform: 'Platform', + catMode: 'Kind', + language: 'Language', + noGames: 'No installable titles in the catalog.', + noMatch: 'Nothing in this category.', + setupTitle: 'Set up a store', + setupBody: 'No store on this machine yet. Pick one and it will be downloaded — the same files the shell installer would place, in the same folder.', + setupAction: 'Download the store', + setupWorking: 'Setting up…', + setupChoose: 'Store', + registryFailed: 'The list of stores could not be fetched', + registryEmpty: 'The list of stores came back empty. Nothing to install from yet.', + registryRetry: 'Try again', + oldEngineTitle: 'The store needs refreshing', + oldEngineBody: 'The store engine on this machine is older than this client can drive. Refreshing it downloads the current engine and keeps your settings and installed games.', + oldEngineAction: 'Refresh the store', + noPythonTitle: 'Python 3 is required', + noPythonBody: 'The store is a Python program, so Python 3 has to be installed. Install it, then reopen this window.', + pythonLink: 'python.org/downloads', + paths: 'Where things go', + openStoreFolder: 'Open the store folder', + openMenuFolder: 'Open the menu folder', + busy: 'Working…', + failed: 'failed', + removed: 'removed', + upToDate: 'Everything is up to date.', + of: 'of' +} as const + +/** Every string the window can show, by key. */ +export type MessageKey = keyof typeof ENGLISH_MESSAGES diff --git a/src/shared/i18n/HungarianMessages.ts b/src/shared/i18n/HungarianMessages.ts new file mode 100644 index 0000000..be90ac5 --- /dev/null +++ b/src/shared/i18n/HungarianMessages.ts @@ -0,0 +1,60 @@ +import type { MessageBundle } from './MessageBundle' + +/** + * Hungarian. Catalog text — titles, descriptions — is never translated here: it + * arrives from the store as it was published. + */ +export const HUNGARIAN_MESSAGES: MessageBundle = { + appName: 'WarpEngine Store', + syncAll: 'Mind telepítése', + refresh: 'Frissítés', + install: 'Telepítés', + update: 'Frissítés', + play: 'Indítás', + open: 'Megnyitás', + remove: 'Eltávolítás', + installed: 'telepítve', + native: 'natív', + hosted: 'hosztolt', + hostedHint: 'A böngészőben nyílik meg — internet kell hozzá', + nativeHint: 'Erre a gépre telepítve — internet nélkül is megy', + updateAvailable: 'frissítés elérhető', + log: 'Napló', + menu: 'Menü', + stores: 'Store-ok', + addStore: 'Store hozzáadása…', + switchFailed: 'Ez a store nem nyitható meg', + actions: 'Műveletek', + categories: 'Kategóriák', + catAll: 'Minden', + catInstalled: 'Telepítve', + catUpdates: 'Frissítés', + catAvailable: 'Nincs telepítve', + catPlatform: 'Platform', + catMode: 'Fajta', + language: 'Nyelv', + noGames: 'Nincs telepíthető cím a katalógusban.', + noMatch: 'Ebben a kategóriában nincs semmi.', + setupTitle: 'Store beállítása', + setupBody: 'Ezen a gépen még nincs store. Válassz egyet, és letöltöm — ugyanazokat a fájlokat, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.', + setupAction: 'Store letöltése', + setupWorking: 'Beállítás…', + setupChoose: 'Store', + registryFailed: 'A store-ok listája nem érhető el', + registryEmpty: 'A store-ok listája üresen jött vissza. Egyelőre nincs miből telepíteni.', + registryRetry: 'Újra', + oldEngineTitle: 'A store frissítésre vár', + oldEngineBody: 'A gépen lévő store-motor régebbi, mint amit ez a kliens vezérelni tud. A frissítés letölti a mostani motort, a beállításaid és a telepített játékok pedig megmaradnak.', + oldEngineAction: 'Store frissítése', + noPythonTitle: 'Python 3 kell hozzá', + noPythonBody: 'A store egy Python program, tehát Python 3 kell a gépre. Telepítsd, majd nyisd meg újra ezt az ablakot.', + pythonLink: 'python.org/downloads', + paths: 'Hova kerül', + openStoreFolder: 'Store könyvtár megnyitása', + openMenuFolder: 'Menü könyvtár megnyitása', + busy: 'Dolgozom…', + failed: 'hiba', + removed: 'eltávolítva', + upToDate: 'Minden naprakész.', + of: '/' +} diff --git a/src/shared/i18n/MessageBundle.ts b/src/shared/i18n/MessageBundle.ts new file mode 100644 index 0000000..9886c55 --- /dev/null +++ b/src/shared/i18n/MessageBundle.ts @@ -0,0 +1,9 @@ +import type { MessageKey } from './EnglishMessages' + +/** One complete language. Partial bundles are not a thing: the type forbids them. */ +export type MessageBundle = Readonly> + +/** The languages the window speaks. The public site has the same two. */ +export const LOCALES = ['en', 'hu'] as const + +export type Locale = (typeof LOCALES)[number] diff --git a/src/shared/i18n/TranslationCatalog.ts b/src/shared/i18n/TranslationCatalog.ts new file mode 100644 index 0000000..9625b4f --- /dev/null +++ b/src/shared/i18n/TranslationCatalog.ts @@ -0,0 +1,30 @@ +import { ENGLISH_MESSAGES } from './EnglishMessages' +import { HUNGARIAN_MESSAGES } from './HungarianMessages' +import { LOCALES, type Locale, type MessageBundle } from './MessageBundle' + +const BUNDLES: Readonly> = { + en: ENGLISH_MESSAGES, + hu: HUNGARIAN_MESSAGES +} + +const FALLBACK_LOCALE: Locale = 'en' + +/** + * Which language, and its strings. + * + * Used on both sides of the bridge: the main process resolves the locale and + * ships the bundle, the window renders from it. + */ +export class TranslationCatalog { + public readonly locales: readonly Locale[] = LOCALES + + /** A system locale like `hu-HU`, or anything at all, mapped onto a language. */ + public resolveLocale (candidate: string | null | undefined): Locale { + const short = (candidate ?? '').slice(0, 2).toLowerCase() + return LOCALES.find((locale: Locale): boolean => locale === short) ?? FALLBACK_LOCALE + } + + public readBundle (candidate: string | null | undefined): MessageBundle { + return BUNDLES[this.resolveLocale(candidate)] + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..7c58831 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": false + }, + "exclude": [ + "src/renderer/**/*", + "src/preload/**/*" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fa8ae38 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "node16", + "esModuleInterop": true, + "rootDir": "src", + "outDir": "build", + "types": ["node"], + + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "useUnknownInCatchVariables": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "declaration": false, + "sourceMap": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}