diff --git a/Makefile b/Makefile index 3332948..997c741 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# WarpEngine Store GUI — the front door to the npm scripts. +# WarpEngine Client — the front door to the npm scripts. # # Everything here is a thin wrapper: the app is an Electron project, so npm still # does the work. The Makefile exists so the useful sequences have names, and so @@ -22,13 +22,23 @@ NODE_MIN := 22 VERSION := $(shell python3 -c 'import json; print(json.load(open("package.json"))["version"])') TAG ?= v$(VERSION) +# Which site's store registry a packaged build reads. Empty means the default in +# package.json (ours); set it to build a client for somebody else's catalog: +# +# make dist STORES_API=https://games.example.org/api/stores +# +# It is baked into the package's own package.json, so the built app carries it. A runtime +# STORES_API still overrides it, which is for trying something out rather than shipping. +STORES_API ?= +BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registryUrl=$(STORES_API),) + .DEFAULT_GOAL := help .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 " + @echo "WarpEngine Client $(VERSION) — usage: make " @echo @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}' @@ -74,16 +84,16 @@ uitest: ## Load the window once and report what rendered test: smoke uitest ## Both checks dist: node-check ## Package for this machine - npm run dist + npm run dist $(BUILDER_ARGS) dist-mac: node-check ## Package for macOS (ad-hoc signed, see the README) - npm run dist:mac + npm run dist:mac $(BUILDER_ARGS) dist-win: node-check ## Package for Windows - npm run dist:win + npm run dist:win $(BUILDER_ARGS) dist-linux: node-check ## Package for Linux - npm run dist:linux + npm run dist:linux $(BUILDER_ARGS) publish: ## Upload the packages already in dist/ to the Gitea release @TAG=$(TAG) $(SCRIPTS)/release.sh @@ -107,3 +117,5 @@ version: ## Show the versions involved @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" + @printf "registry "; python3 -c 'import json; print(json.load(open("package.json"))["warpEngine"]["registryUrl"])' + @if [ -n "$(STORES_API)" ]; then printf " build override: %s\n" "$(STORES_API)"; fi diff --git a/README.md b/README.md index 08c25e2..5677813 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# warp-engine-client — the WarpEngine Store app +# warp-engine-client — the WarpEngine Client app The graphical client for a WarpEngine store — the app is called **WarpEngine Store** — driving @@ -41,7 +41,7 @@ The build is ad-hoc signed but **not notarised**, so macOS asks before running a copy that came from a browser. The reliable way through: ```sh -xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app" +xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app" ``` If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** after @@ -98,13 +98,19 @@ What the defaults produce, for a record with no repository: the games land in a folder named after the store id, and released, archived **and demo** titles are listed — a catalog that publishes a demo means it to be played. -The registry address is the single thing about a particular site left in the -client, and `STORES_API` overrides it: +The registry address is the single thing about a particular site left in the client, +and it is decided in three places, most specific first: ```sh -STORES_API=http://127.0.0.1:8731/stores npm start +STORES_API=http://127.0.0.1:8731/stores npm start # runtime: for trying something out +make dist STORES_API=https://games.example.org/api/stores # build: for shipping it ``` +The build variant is baked into the packaged app's own `package.json` +(`warpEngine.registryUrl`, written by `electron-builder --config.extraMetadata`), so a +client built for somebody else's catalog needs no source change and no environment on the +user's machine. With neither set, the address is ours. + Adding a store is therefore a database row on the site — see its ActiveAdmin panel — and not a release of this app. @@ -139,6 +145,14 @@ once it is there. **Remove** takes a title back out. Each card says whether it i build the catalog serves rather than packages, so its entry opens a page and needs the network. +**Everything in the catalog is listed, including what this machine cannot install.** +Those cards are dimmed, carry an *unsupported platform* or *no build for this machine* +badge with the engine's own explanation under it, and have nothing to press. A store +that hides them leaves you wondering whether the catalog is small or your machine is +unusual; this way it says which. They have a category of their own — *Not for this +machine* — and they are left out of the native/hosted counts, because a title with no +build has no mode to be counted under. + Every card carries a band of box art the same height — the first letter of the title when the catalog has no image — so titles and buttons line up across a row. Until this was photographed, the grid was quietly broken: the rows split the @@ -261,7 +275,7 @@ on its own: publishing 1.2.0 got *"invalid username, password or token"* on the second package while the first had just gone up with the same token, and the same command succeeded immediately afterwards. -Package names contain a space — `WarpEngine Store-1.2.0-arm64.dmg` — so the list of +Package names contain a space — `WarpEngine Client-1.5.0-arm64.dmg` — so the list of files is passed one path per line rather than as one string; splitting it on whitespace is what broke the first attempt at publishing 1.1.0. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 09d5139..ca83ed7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,90 +1,55 @@ -# WarpEngine Store 1.4.0 +# WarpEngine Client 1.5.0 -**A store no longer needs a repository of its own.** Until now every store in the -registry pointed at a repository holding its `config.json`, and the client read that -file to know what to install. It turns out almost nothing in there was necessary: the -store engine's built-in defaults already cover the host-to-asset mapping, the install -modes, the platforms and the behaviour. What defaults cannot know is *identity* — a -slug, a name and a catalog URL — and that is exactly what a registry record carries. +**The app is called WarpEngine Client.** It was WarpEngine Store, which named the thing +it opens rather than the thing you run — and the store is a catalog on a site, not a +window on your machine. The window title, the packages, the bundle and the menu entry all +say Client now; the repository already did. The store the window is driving is named in +the side menu, so the bar no longer repeats it as a badge. -So `storeRepositoryUrl` is now optional. A record with a name and a catalog URL is a -complete store: the client derives the slug from the catalog host -(`teletypegames.org` → `teletypegames`), writes a small config and installs. Given a -repository it still reads it, and that file remains the authority on how the store -behaves — which platforms it offers, which statuses it shows, where things land. A -repository without a `config.json` is treated as no repository at all. +**Every title in the catalog is listed, including the ones this machine cannot install.** +A C64 cartridge on a desktop, or a game with no build for your operating system, used to +be silently absent — leaving you to wonder whether the catalog is small or your machine +is unusual. Those cards are now there, dimmed, with an *unsupported platform* or *no +build for this machine* badge and the engine's own sentence underneath, and with nothing +to press. They have a category of their own, *Not for this machine*, and they stay out of +the native/hosted counts: a title with no build has no mode to be counted under. -With the defaults, games land in a folder named after the store id and released, -archived **and demo** titles are listed: a catalog that publishes a demo means it to -be played. +This needs store engine **1.2.0** (desktop) on **warpstore 1.4.0**, which report what +they had to leave out and why. An older engine still works — its listing is simply all +installable, as it was before. -The site's registry endpoint changed to match — `storeRepositoryUrl` answers `null` -when there is none — and adding a store is now genuinely one database row with two -fields filled in. +**A build can be pointed at another site's registry.** -**The "Install all" button is gone.** Titles are installed one at a time from their -own cards. +```sh +make dist STORES_API=https://games.example.org/api/stores +``` -**No footer.** The window carried a bar at the bottom at all times — a toggle and a -line of absolute paths — for something most sessions never need. The log is still -there, with the two folder buttons in it, but it lives behind a quiet switch at the -bottom of the side menu and takes no room until it is opened. The grid gets the height -back. +The address is written into the packaged app's own `package.json`, so a client built for +somebody else's catalog needs no source change and nothing set on the user's machine. A +runtime `STORES_API` still wins, which is for trying something out rather than shipping. -**The repository is now `warp-engine-client`.** The app has always been called -WarpEngine Store; `warp-engine-desktop-gui` described the role rather than the -product, and the host-specific engines keep their own shape -(`warp-engine-desktop-store`, `-retroarch-store`, `-batocera-store`). Gitea keeps a -redirect from the old path, and the releases and tags moved with the repository, so -existing links and clones still resolve. +### Also -### Three things a screenshot found - -Photographing the setup screen — which no automated count had ever looked at — turned -up three faults that every check had passed: - -- the store badge in the bar rendered as an empty pill when no store was open; -- the gate's store picker showed as an empty dropdown stub, because an explicit - `display` in the stylesheet beats the browser's own `[hidden]` rule; -- the gate went up while the *"No installable titles in the catalog"* line stayed on - screen underneath it. - -The last one was a design fault, not a typo: whether the gate is up was an imperative -call on a view rather than state, so the gate and the grid could disagree. The setup -screen is now a field in the state store, and that one field decides which of the two -is drawn. The window test's gate assertion was wrong too — it demanded a store picker, -which only appears when the registry offers more than one store, so a perfectly good -window failed it. - -### Linux and Windows packages now come from CI - -A `vX.Y.Z` tag now starts the pipeline, which builds the AppImage, the deb, the NSIS -installer and the portable exe — Windows through Wine — **creates this release** and -attaches all four. macOS stays a local build, because Apple's toolchain and its signing -exist only on a Mac, so `make release` from a Mac pushes that package onto the same -release afterwards. Publishing uses a `gitea_token` repository secret in Woodpecker. - -The Windows installer is not signed: Windows will warn about an unknown publisher until -there is a certificate. +The scrollbars are the window's own now: the platform's light track down the side menu of +a dark window looked like a mistake. ### Opening it on macOS Ad-hoc signed, **not notarised**, so macOS asks first: ```sh -xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app" +xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app" ``` ### What is attached -The macOS arm64 package, built and verified here, plus whatever the pipeline attaches -for Linux (AppImage, deb) and Windows (installer, portable). +The macOS package, built and verified on a Mac, plus the Linux (AppImage, deb) and +Windows (installer, portable) packages the pipeline builds when the tag is pushed. ### Verified -A repository-less store was installed end to end against a local registry serving one -record with `storeRepositoryUrl: null`: the id came out as `teletypegames`, the engine -and the shared core downloaded, the written config had three sections, engine 1.1.0 -accepted it, it listed the same ten titles the configured store does, and a hosted -title synced into a sandbox with its menu entry written. `make check` is clean, and -the setup gate was photographed on a machine with no store at all. +`make check` is clean. The store on this machine lists 13 titles — ten installable, three +C64 cartridges this store does not carry — and the window was photographed with all +thirteen on screen, the three dimmed and labelled. The engine change was measured through +the CLI as well, in both its human and its `--json` listing, and the other two store +engines still get the two-value answer they ask for. diff --git a/STRUCTURE.md b/STRUCTURE.md index 3074bea..6e78a14 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -62,6 +62,7 @@ src/ mappers/ engine JSON → domain http/ HttpTextClient, HttpStatusError json/ JsonRecord: reading data that came from elsewhere + config/ BuildConfiguration: what was decided when this was packaged electron/ ApplicationEnvironment and GameLauncher adapters main/ main.ts the entry point: one line of work @@ -184,6 +185,14 @@ never calls the bridge. 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. +### Build-time configuration + +`infrastructure/config/BuildConfiguration.ts` reads the packaged `package.json`, which is +where a build records the registry it was made for (`warpEngine.registryUrl`, set by +`make dist STORES_API=…`). Precedence is runtime environment, then build, then the +built-in default — most specific first, and each one is a different audience: someone +trying it out, someone shipping a client for another site, us. + ### Untrusted-data readers Anything parsed from outside — engine stdout, the registry — goes through diff --git a/package.json b/package.json index 15cc99a..7d0d6fb 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "warp-engine-client", - "productName": "WarpEngine Store", - "version": "1.4.0", - "description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.", + "productName": "WarpEngine Client", + "version": "1.5.0", + "description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.", "license": "MIT", "author": "Teletype Games ", "homepage": "https://git.teletypegames.org/stores/warp-engine-client", @@ -34,7 +34,7 @@ }, "build": { "appId": "org.teletypegames.warpstore.gui", - "productName": "WarpEngine Store", + "productName": "WarpEngine Client", "files": [ "build/**/*", "package.json" @@ -64,5 +64,8 @@ "allowScripts": { "electron@43.4.0": true, "esbuild@0.28.2": true + }, + "warpEngine": { + "registryUrl": "https://teletypegames.org/api/stores" } } diff --git a/scripts/ci-upload.sh b/scripts/ci-upload.sh index 639a2ca..4679928 100755 --- a/scripts/ci-upload.sh +++ b/scripts/ci-upload.sh @@ -53,7 +53,7 @@ api() { curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@" } -# Package names contain spaces — "WarpEngine Store Setup 1.4.0.exe" does — so the list +# Package names contain spaces — "WarpEngine Client Setup 1.5.0.exe" does — so the list # lives one path per line in a file and is read with `while IFS= read -r`. A single # variable looped over with $list splits on the space and uploads nothing. LIST="$(mktemp)" diff --git a/scripts/release.sh b/scripts/release.sh index 69e3e18..2fc2c3a 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -52,7 +52,7 @@ REPO="${REPO:-$(git remote get-url origin 2>/dev/null | # # - the version filter, because dist/ keeps whatever earlier builds left there # and a release would quietly get the previous version's files attached; -# - the spaces. "WarpEngine Store-1.1.0-arm64.dmg" has one, so the list lives one +# - the spaces. "WarpEngine Client-1.5.0-arm64.dmg" has one, so the list lives one # path per line in a file and is read with `while IFS= read -r`. Holding it in # a single variable and looping over $list splits it on the space. LIST="$(mktemp)" diff --git a/src/application/mappers/GameDtoMapper.ts b/src/application/mappers/GameDtoMapper.ts index 8ab4b29..1a763b7 100644 --- a/src/application/mappers/GameDtoMapper.ts +++ b/src/application/mappers/GameDtoMapper.ts @@ -26,7 +26,10 @@ export class GameDtoMapper { installed: game.installed, updateAvailable: game.updateAvailable, installedVersion: game.installedVersion, - launchable: this.isLaunchable(game) + launchable: this.isLaunchable(game), + installable: game.installable, + unavailableReason: game.unavailableReason, + unavailableDetail: game.unavailableDetail } } diff --git a/src/domain/models/Game.ts b/src/domain/models/Game.ts index 1ad4474..51382fa 100644 --- a/src/domain/models/Game.ts +++ b/src/domain/models/Game.ts @@ -1,6 +1,15 @@ /** How a title runs: unpacked on this machine, or served as a web build. */ export type GameMode = 'app' | 'web' +/** + * Why a title cannot be installed here, as the engine codes it. + * + * `platformOff` is the store not carrying that platform at all — a C64 cartridge on a + * desktop — and the other three are about this machine or this catalog: no asset kind + * for the os and architecture, no release carrying it, or the adapter refusing it. + */ +export type UnavailableReason = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed' + /** * A catalog entry, with what the store did about it on this machine. * @@ -24,4 +33,12 @@ export interface Game { readonly menuEntryPath: string | null readonly executablePath: string | null readonly hostedUrl: string | null + /** + * False for a title this machine cannot install. It is still listed: a catalog that + * hides what your machine cannot run leaves you wondering which of the two is small. + */ + readonly installable: boolean + readonly unavailableReason: UnavailableReason | null + /** The engine's sentence for it, for a tooltip or the log. */ + readonly unavailableDetail: string | null } diff --git a/src/infrastructure/config/BuildConfiguration.ts b/src/infrastructure/config/BuildConfiguration.ts new file mode 100644 index 0000000..46289fa --- /dev/null +++ b/src/infrastructure/config/BuildConfiguration.ts @@ -0,0 +1,49 @@ +import fs from 'node:fs' +import path from 'node:path' +import { asRecord, readRecord, readString } from '../json/JsonRecord' + +/** + * What was decided when this package was built. + * + * The registry address is the one thing about a particular site left in the client, and + * a build for a different site should not need a different source tree. So it is a field + * in `package.json`, which `electron-builder` can overwrite at packaging time: + * + * make dist STORES_API=https://staging.example.org/api/stores + * + * Read from the package.json that ships inside the app, so a packaged build answers with + * what it was built with. A runtime `STORES_API` still wins over it — that is for trying + * something out, this is for shipping it. + */ +export class BuildConfiguration { + private cached: Readonly> | null = null + + public readRegistryUrl (): string | null { + const section = readRecord(this.read(), 'warpEngine') + if (section === null) return null + const url = readString(section, 'registryUrl').trim() + return url.length > 0 ? url : null + } + + private read (): Readonly> { + if (this.cached !== null) return this.cached + // build/infrastructure/config → the package root, packaged or not. + const candidates = [ + path.join(__dirname, '..', '..', '..', 'package.json'), + path.join(__dirname, '..', '..', 'package.json') + ] + for (const candidate of candidates) { + try { + const parsed = asRecord(JSON.parse(fs.readFileSync(candidate, 'utf8'))) + if (parsed !== null) { + this.cached = parsed + return parsed + } + } catch { + // Try the next one; a missing package.json is only fatal if none is found. + } + } + this.cached = {} + return this.cached + } +} diff --git a/src/infrastructure/mappers/EngineGameMapper.ts b/src/infrastructure/mappers/EngineGameMapper.ts index 281976f..9c25bc2 100644 --- a/src/infrastructure/mappers/EngineGameMapper.ts +++ b/src/infrastructure/mappers/EngineGameMapper.ts @@ -1,4 +1,4 @@ -import type { Game, GameMode } from '../../domain/models/Game' +import type { Game, GameMode, UnavailableReason } from '../../domain/models/Game' import { readBoolean, readOptionalString, readString, type JsonRecord } from '../json/JsonRecord' @@ -26,11 +26,27 @@ export class EngineGameMapper { installedVersion: readOptionalString(record, 'installed_version'), menuEntryPath: readOptionalString(record, 'menu_entry'), executablePath: readOptionalString(record, 'exe'), - hostedUrl: readOptionalString(record, 'url') + hostedUrl: readOptionalString(record, 'url'), + // Absent means installable: engines older than 1.2.0 list only what they can + // install, and treating their silence as "unavailable" would empty the window. + installable: readBoolean(record, 'installable', true), + unavailableReason: this.toReason(readOptionalString(record, 'unavailable_reason')), + unavailableDetail: readOptionalString(record, 'unavailable_detail') } } private toMode (value: string): GameMode { return value === 'web' ? 'web' : 'app' } + + /** The engine's snake_case codes, which are its wire format and not ours. */ + private toReason (value: string | null): UnavailableReason | null { + const codes: Readonly> = { + platform_off: 'platformOff', + host_asset: 'hostAsset', + no_asset: 'noAsset', + vetoed: 'vetoed' + } + return value === null ? null : codes[value] ?? null + } } diff --git a/src/infrastructure/repositories/HttpStoreRegistryRepository.ts b/src/infrastructure/repositories/HttpStoreRegistryRepository.ts index 9c3cb52..f6b653d 100644 --- a/src/infrastructure/repositories/HttpStoreRegistryRepository.ts +++ b/src/infrastructure/repositories/HttpStoreRegistryRepository.ts @@ -2,6 +2,7 @@ import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailabl import type { RegistryStore } from '../../domain/models/RegistryStore' import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository' import { asRecord, readString, type JsonRecord } from '../json/JsonRecord' +import { BuildConfiguration } from '../config/BuildConfiguration' import type { HttpTextClient } from '../http/HttpTextClient' const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores' @@ -9,8 +10,10 @@ 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. + * The one address this client knows, and it is decided in three places, most specific + * first: a runtime `STORES_API` (for trying something out), the `warpEngine.registryUrl` + * field a build was packaged with (for shipping a client for another site), and finally + * the address of ours. * * A record needs a name and a catalog URL; those two make a store. The repository * is optional and arrives as null when absent — a store configured by nothing but @@ -20,11 +23,16 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores' 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 constructor ( + private readonly httpClient: HttpTextClient, + sourceUrl?: string, + buildConfiguration: BuildConfiguration = new BuildConfiguration() + ) { + const fromEnvironment = process.env['STORES_API'] + this.sourceUrl = sourceUrl + ?? (fromEnvironment !== undefined && fromEnvironment.length > 0 ? fromEnvironment : null) + ?? buildConfiguration.readRegistryUrl() + ?? DEFAULT_REGISTRY_URL } public async listStores (): Promise { diff --git a/src/main/ElectronApplication.ts b/src/main/ElectronApplication.ts index 9a69c21..7b5b1c7 100644 --- a/src/main/ElectronApplication.ts +++ b/src/main/ElectronApplication.ts @@ -6,7 +6,7 @@ import { SelfTestRunner } from './diagnostics/SelfTestRunner' const SELFTEST_FLAG = '--selftest' const SELFTEST_USER_DATA_DIRECTORY = 'warpstore-gui-selftest' -const PRODUCT_NAME = 'WarpEngine Store' +const PRODUCT_NAME = 'WarpEngine Client' /** * The application's lifecycle. diff --git a/src/main/MainWindowFactory.ts b/src/main/MainWindowFactory.ts index 8280bed..904d40c 100644 --- a/src/main/MainWindowFactory.ts +++ b/src/main/MainWindowFactory.ts @@ -7,7 +7,7 @@ const WINDOW_OPTIONS: BrowserWindowConstructorOptions = { minWidth: 760, minHeight: 520, backgroundColor: '#11151c', - title: 'WarpEngine Store' + title: 'WarpEngine Client' } /** diff --git a/src/renderer/index.html b/src/renderer/index.html index acf0f7d..22bf5ed 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ runs: the app ships its own script and stylesheet. --> - WarpEngine Store + WarpEngine Client @@ -16,8 +16,10 @@
- WarpEngine Store - + WarpEngine Client + +
diff --git a/src/renderer/state/CategoryFilter.ts b/src/renderer/state/CategoryFilter.ts index 39c9178..3a23b7d 100644 --- a/src/renderer/state/CategoryFilter.ts +++ b/src/renderer/state/CategoryFilter.ts @@ -30,7 +30,9 @@ export function matchesFilter (game: GameDto, filter: CategoryFilter): boolean { case 'platform': return game.platform === filter.value case 'mode': - return game.mode === filter.value + // A title this machine cannot install has no mode worth filtering on: the engine + // sends none, and counting it as native would put a C64 cartridge under "native". + return game.installable && game.mode === filter.value case 'group': return matchesGroup(game, filter.value) } @@ -43,7 +45,9 @@ function matchesGroup (game: GameDto, group: string): boolean { case 'updates': return game.updateAvailable case 'available': - return !game.installed + return game.installable && !game.installed + case 'unsupported': + return !game.installable default: return true } @@ -68,7 +72,8 @@ export function buildCategorySections ( { 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) } + { kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => game.installable && !game.installed) }, + { kind: 'group', value: 'unsupported', label: messages.catUnsupported, count: count((game: GameDto): boolean => !game.installable) } ] sections.push({ title: null, @@ -90,7 +95,8 @@ export function buildCategorySections ( }) } - const modes = [...new Set(games.map((game: GameDto): string => game.mode))] + const installable = games.filter((game: GameDto): boolean => game.installable) + const modes = [...new Set(installable.map((game: GameDto): string => game.mode))] if (modes.length > 1) { sections.push({ title: messages.catMode, @@ -98,7 +104,7 @@ export function buildCategorySections ( kind: 'mode', value: mode, label: mode === 'web' ? messages.hosted : messages.native, - count: count((game: GameDto): boolean => game.mode === mode) + count: count((game: GameDto): boolean => game.installable && game.mode === mode) })) }) } diff --git a/src/renderer/style.css b/src/renderer/style.css index b22c8d4..ea120bc 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -14,6 +14,22 @@ * { box-sizing: border-box; } +/* The scrollbars are part of the theme too: the platform's own are light, and a white + track down the side menu of a dark window looks like a mistake. */ +* { + scrollbar-width: thin; + scrollbar-color: #33414f transparent; +} +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: #33414f; + border: 3px solid transparent; + border-radius: 999px; + background-clip: content-box; +} +::-webkit-scrollbar-thumb:hover { background: #46586b; background-clip: content-box; } + /* An explicit `display` beats the browser's own [hidden] rule, and most of the regions here have one — the gate's store picker showed as an empty stub because of exactly that. This makes `hidden` mean hidden everywhere. */ @@ -248,6 +264,12 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); } flex-direction: column; } .card.is-installed { border-color: #2f5a49; } +/* Listed, but not for this machine: dimmed rather than hidden, and it says why. */ +.card.is-unavailable { opacity: .55; } +.card.is-unavailable:hover { opacity: .8; } +.card.is-unavailable .art { filter: grayscale(1); } +.badge-unavailable { color: var(--ink-dim); border-color: var(--line); border-style: dashed; } +.actions-note { font-size: 12px; color: var(--ink-dim); margin-top: auto; } .art { /* One height for every card, art or not, so the titles and the buttons line up diff --git a/src/renderer/views/GameCardView.ts b/src/renderer/views/GameCardView.ts index e5ccd33..1b58f54 100644 --- a/src/renderer/views/GameCardView.ts +++ b/src/renderer/views/GameCardView.ts @@ -20,6 +20,7 @@ export class GameCardView { public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement { const card = createElement('article', 'card') if (game.installed) card.classList.add('is-installed') + if (!game.installable) card.classList.add('is-unavailable') card.appendChild(this.createArt(game)) card.appendChild(this.createBody(game, messages, busy)) return card @@ -54,10 +55,21 @@ export class GameCardView { 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) + if (game.installable) { + 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) + } else { + // Which of the two it is matters: a platform this store does not carry is a + // different disappointment from a game with no build for your machine. + const label = game.unavailableReason === 'platformOff' + ? messages.unsupportedPlatform + : messages.unsupportedBuild + const badge = createElement('span', 'badge badge-unavailable', label) + badge.title = game.unavailableDetail ?? label + meta.appendChild(badge) + } meta.appendChild(createElement('span', 'badge badge-plain', game.platform)) meta.appendChild(createElement('span', 'version', game.installed && game.installedVersion !== null @@ -68,6 +80,15 @@ export class GameCardView { private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement { const actions = createElement('div', 'actions') + + // Nothing to offer, so nothing to press: a disabled Install would invite a click + // that can never work. The badge above says why. + if (!game.installable) { + actions.appendChild(createElement('span', 'actions-note', + game.unavailableDetail ?? messages.unsupportedPlatform)) + return actions + } + const primary = createElement('button', 'btn btn-primary') primary.disabled = busy diff --git a/src/renderer/views/TopBarView.ts b/src/renderer/views/TopBarView.ts index fef23e7..839023b 100644 --- a/src/renderer/views/TopBarView.ts +++ b/src/renderer/views/TopBarView.ts @@ -18,8 +18,9 @@ export class TopBarView { public render (state: AppState): void { setText(this.appName, state.messages.appName) - // The badge is a bordered pill: empty, it renders as a stub next to the title. - setHidden(this.storeId, state.currentStore === null) + // The bar names the app, not the store: which store is open is what the switcher in + // the side menu says, and saying it twice made the title read like a breadcrumb. The + // element stays in the page, hidden, because the window check reads it. setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id) this.navToggle.title = state.messages.menu this.navToggle.setAttribute('aria-label', state.messages.menu) diff --git a/src/scripts/SmokeTest.ts b/src/scripts/SmokeTest.ts index 29fbe57..8919b49 100644 --- a/src/scripts/SmokeTest.ts +++ b/src/scripts/SmokeTest.ts @@ -167,9 +167,22 @@ class SmokeTest { } 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 installable = games.filter((game: GameDto): boolean => game.installable) + const native = installable.filter((game: GameDto): boolean => game.mode === 'app').length + const hosted = installable.length - native + this.reportOk('list', `${String(games.length)} titles ` + + `(app:${String(native)}, web:${String(hosted)}, unavailable:${String(games.length - installable.length)})`) + + // The listing is supposed to carry what it cannot install, with a reason on each. + const unavailable = games.filter((game: GameDto): boolean => !game.installable) + const unexplained = unavailable.filter((game: GameDto): boolean => game.unavailableReason === null) + if (unexplained.length > 0) this.reportBad('unavailable', `${String(unexplained.length)} have no reason`) + else if (unavailable.length > 0) { + const first = unavailable[0] + if (first !== undefined) { + this.reportOk('unavailable', `${String(unavailable.length)}, e.g. ${first.name}: ${first.unavailableReason ?? ''}`) + } + } const withoutTitle = games.filter((game: GameDto): boolean => game.name.length === 0 || game.title.length === 0 || game.platform.length === 0) diff --git a/src/shared/contracts/dto/GameDto.ts b/src/shared/contracts/dto/GameDto.ts index 5465318..b762700 100644 --- a/src/shared/contracts/dto/GameDto.ts +++ b/src/shared/contracts/dto/GameDto.ts @@ -1,6 +1,9 @@ /** How a title runs: unpacked on this machine, or served as a web build. */ export type GameModeDto = 'app' | 'web' +/** Why a title cannot be installed on this machine. */ +export type UnavailableReasonDto = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed' + /** * A catalog entry as the window needs it. * @@ -23,4 +26,8 @@ export interface GameDto { readonly updateAvailable: boolean readonly installedVersion: string | null readonly launchable: boolean + /** False for a title this machine cannot install; it is listed all the same. */ + readonly installable: boolean + readonly unavailableReason: UnavailableReasonDto | null + readonly unavailableDetail: string | null } diff --git a/src/shared/i18n/EnglishMessages.ts b/src/shared/i18n/EnglishMessages.ts index d1bb43d..30851c2 100644 --- a/src/shared/i18n/EnglishMessages.ts +++ b/src/shared/i18n/EnglishMessages.ts @@ -5,7 +5,7 @@ * translation is a compile error rather than a blank label at runtime. */ export const ENGLISH_MESSAGES = { - appName: 'WarpEngine Store', + appName: 'WarpEngine Client', refresh: 'Refresh', install: 'Install', update: 'Update', @@ -18,6 +18,8 @@ export const ENGLISH_MESSAGES = { hostedHint: 'Opens in your browser — needs the network', nativeHint: 'Installed on this machine — works offline', updateAvailable: 'update available', + unsupportedPlatform: 'unsupported platform', + unsupportedBuild: 'no build for this machine', log: 'Log', menu: 'Menu', stores: 'Stores', @@ -29,6 +31,7 @@ export const ENGLISH_MESSAGES = { catInstalled: 'Installed', catUpdates: 'Updates', catAvailable: 'Not installed', + catUnsupported: 'Not for this machine', catPlatform: 'Platform', catMode: 'Kind', language: 'Language', diff --git a/src/shared/i18n/HungarianMessages.ts b/src/shared/i18n/HungarianMessages.ts index 08d9cce..69d9dff 100644 --- a/src/shared/i18n/HungarianMessages.ts +++ b/src/shared/i18n/HungarianMessages.ts @@ -5,7 +5,7 @@ import type { MessageBundle } from './MessageBundle' * arrives from the store as it was published. */ export const HUNGARIAN_MESSAGES: MessageBundle = { - appName: 'WarpEngine Store', + appName: 'WarpEngine Client', refresh: 'Frissítés', install: 'Telepítés', update: 'Frissítés', @@ -18,6 +18,8 @@ export const HUNGARIAN_MESSAGES: MessageBundle = { 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ő', + unsupportedPlatform: 'nem támogatott platform', + unsupportedBuild: 'ehhez a géphez nincs build', log: 'Napló', menu: 'Menü', stores: 'Store-ok', @@ -29,6 +31,7 @@ export const HUNGARIAN_MESSAGES: MessageBundle = { catInstalled: 'Telepítve', catUpdates: 'Frissítés', catAvailable: 'Nincs telepítve', + catUnsupported: 'Erre a gépre nem', catPlatform: 'Platform', catMode: 'Fajta', language: 'Nyelv',