diff --git a/Makefile b/Makefile index 83f9ab7..e54f036 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registry .DEFAULT_GOAL := help -.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest storetest test \ +.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest storetest icons test \ dist dist-mac dist-win dist-linux release publish clean distclean version help: ## List available targets @@ -83,6 +83,9 @@ smoke: ## Drive the store bridge with no window at all uitest: ## Load the window once and report what rendered npm run uitest +icons: ## Render every icon format from resources/icon.svg + npm run icons + storetest: ## Add and remove a store in a sandbox (the only code that deletes a tree) npm run storetest diff --git a/README.md b/README.md index 2ba71e1..eba3254 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ names. `make` on its own lists everything. | `make lint` | the strict rule set (`lint-fix` fixes what it can) | | `make check` | **typecheck, lint and every test suite** — the gate | | `make start` | run the app against whatever store is installed | +| `make icons` | render every icon format from `resources/icon.svg` | | `make smoke` | drive the store with no window and no Electron at all | | `make storetest` | add and remove a store in a sandbox — the only code that deletes a tree | | `SMOKE_HOME= SMOKE_TOKEN= npm run smoke` | the same, against a sandbox store and as a signed-in person | @@ -246,6 +247,23 @@ The npm scripts still work directly (`npm start`, `npm run dist:mac`) — the 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. +### The icon + +`resources/icon.svg` is the source and the only file to edit; `make icons` renders the +rest — `icon.png`, `icon.ico`, `icon.icns` and the `icons/` directory Linux packages +want. Three committed binaries with no way to regenerate them is how an icon becomes +something nobody dares change, so the render is a script rather than a memory. + +It needs `rsvg-convert` (`brew install librsvg`, `apt install librsvg2-bin`). The +`.icns` step additionally needs `iconutil`, which exists only on macOS — elsewhere it +is skipped with a warning and the committed `.icns` stands, which is what a mac build +uses anyway. + +The mark is a **W with three lines running into it**: the product's initial, and what +it is doing. It was drawn for the smallest size first — at 32px the W still reads and +the lines survive as motion rather than as noise. A portal, a play triangle and a send +arrow were all tried and all discarded: each already means something else. + ### Continuous integration `.woodpecker.yaml` builds the **Linux and Windows** packages, and on a tag attaches diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 81e433d..04d11dd 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,30 +1,23 @@ -# WarpEngine Client 2.5.0 +# WarpEngine Client 2.5.1 -**A store can be taken off the machine again.** Hovering a store in the side menu shows -a bin. It asks first, and says how many titles will go with it — because removing a -store *uninstalls what it installed*. That is not a convenience: a store's `state.json` -is the only record of which payloads, icons and menu entries belong to it, so leaving -the games behind would leave orphans nothing could ever identify, least of all a later -install of the same store into the same folder. The catalog cache, the settings and any -sign-in token go too. +**The app has an icon.** Until now every build shipped the default Electron one — +`electron-builder` said so on every run, in a line easy to read past: *"default Electron +icon is used, reason=application icon is not set"*. A store you install games with +should not look like a framework demo in the Dock. -**Adding one moved to a + beside Refresh**, and it now takes a catalog address of your -own as well as the ones the registry lists. A bare host is enough — `https` is assumed — -and the name is taken from the address. Both are actions on the whole store rather than -on one of them, which is why they sit together; the full-width "Add a store…" button -under the list read as a third store. +The mark is a **W with three lines running into it**: the product's initial, and what it +is doing. It uses the window's own palette, so the icon and the application it opens are +the same object. It was drawn for the smallest size first — at 32px the W still reads +and the lines survive as motion rather than as noise. A portal, a play triangle and a +send arrow were each tried and each discarded: they already mean a loading spinner, a +media player and a submit button. -**The picker has a way out.** Opening it with a store already installed used to be a -trap: the grid was replaced and nothing short of installing something brought it back. +`resources/icon.svg` is the source and the only file to edit. `make icons` renders the +`.png`, the `.ico`, the `.icns` and the Linux size directory from it — three committed +binaries with no way to regenerate them is how an icon becomes something nobody dares +touch. -**Two things found by the new test.** `make storetest` adds and removes a store in a -sandbox, because removal is the only code here that deletes a directory tree and the -path it deletes is named by the window. It immediately caught that `http://` was -accepted and became a store called *http* — the trailing slashes were being stripped -before the scheme was checked — and that `STORE_ROOT` only *prepended* to the search -path, so a "sandboxed" run still listed the real stores. With a delete button in the -window, a sandbox that can reach a working installation is not a sandbox; it replaces -the search path now. +The window also picks it up when run from source, where there is otherwise no icon to +carry and a dev run looks like a different application from the one being built. -**`btn-secondary` had no styling at all.** It was introduced in 2.4.0 on the card's -sign-in button and on the sign-in panel, and rendered as a plain button in both places. +Nothing else changed: same store handling, same catalog, same sign-in. diff --git a/package-lock.json b/package-lock.json index 9f478c2..c8771f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "warp-engine-client", - "version": "2.4.0", + "version": "2.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "warp-engine-client", - "version": "2.4.0", + "version": "2.5.1", "license": "MIT", "devDependencies": { "@types/node": "^26.2.0", diff --git a/package.json b/package.json index 10fc032..d46bff3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "warp-engine-client", "productName": "WarpEngine Client", - "version": "2.5.0", + "version": "2.5.1", "description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.", "license": "MIT", "author": "Teletype Games ", @@ -23,7 +23,8 @@ "dist:win": "npm run build && electron-builder --win", "dist:linux": "npm run build && electron-builder --linux", "storetest": "npm run build && node build/scripts/StoreLifecycleTest.js", - "test": "npm run smoke && npm run storetest && npm run uitest" + "test": "npm run smoke && npm run storetest && npm run uitest", + "icons": "node scripts/build-icons.mjs" }, "devDependencies": { "@types/node": "^26.2.0", @@ -47,7 +48,8 @@ "dmg", "zip" ], - "artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}" + "artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}", + "icon": "resources/icon.icns" }, "dmg": { "artifactName": "WarpEngineClient-${version}-${arch}.${ext}" @@ -56,7 +58,8 @@ "target": [ "nsis", "portable" - ] + ], + "icon": "resources/icon.ico" }, "nsis": { "artifactName": "WarpEngineClient-Setup-${version}-${arch}.${ext}" @@ -69,12 +72,16 @@ "target": [ "AppImage", "deb" - ] + ], + "icon": "resources/icons" }, "appImage": { "artifactName": "WarpEngineClient-${version}-${arch}.${ext}" }, - "afterPack": "scripts/after-pack.js" + "afterPack": "scripts/after-pack.js", + "directories": { + "buildResources": "resources" + } }, "allowScripts": { "electron@43.4.0": true, diff --git a/resources/icon.icns b/resources/icon.icns new file mode 100644 index 0000000..e6bed0c Binary files /dev/null and b/resources/icon.icns differ diff --git a/resources/icon.ico b/resources/icon.ico new file mode 100644 index 0000000..a17a531 Binary files /dev/null and b/resources/icon.ico differ diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000..dc63059 Binary files /dev/null and b/resources/icon.png differ diff --git a/resources/icon.svg b/resources/icon.svg new file mode 100644 index 0000000..b3210f4 --- /dev/null +++ b/resources/icon.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/icons/1024x1024.png b/resources/icons/1024x1024.png new file mode 100644 index 0000000..dc63059 Binary files /dev/null and b/resources/icons/1024x1024.png differ diff --git a/resources/icons/128x128.png b/resources/icons/128x128.png new file mode 100644 index 0000000..b45f181 Binary files /dev/null and b/resources/icons/128x128.png differ diff --git a/resources/icons/16x16.png b/resources/icons/16x16.png new file mode 100644 index 0000000..ffecaf8 Binary files /dev/null and b/resources/icons/16x16.png differ diff --git a/resources/icons/256x256.png b/resources/icons/256x256.png new file mode 100644 index 0000000..e27a499 Binary files /dev/null and b/resources/icons/256x256.png differ diff --git a/resources/icons/32x32.png b/resources/icons/32x32.png new file mode 100644 index 0000000..cfbf315 Binary files /dev/null and b/resources/icons/32x32.png differ diff --git a/resources/icons/48x48.png b/resources/icons/48x48.png new file mode 100644 index 0000000..643df35 Binary files /dev/null and b/resources/icons/48x48.png differ diff --git a/resources/icons/512x512.png b/resources/icons/512x512.png new file mode 100644 index 0000000..4d5ef0b Binary files /dev/null and b/resources/icons/512x512.png differ diff --git a/resources/icons/64x64.png b/resources/icons/64x64.png new file mode 100644 index 0000000..335f270 Binary files /dev/null and b/resources/icons/64x64.png differ diff --git a/scripts/build-icons.mjs b/scripts/build-icons.mjs new file mode 100644 index 0000000..8c6ea65 --- /dev/null +++ b/scripts/build-icons.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +// Render every icon format from resources/icon.svg. +// +// The point of this script is that the icon stays *editable*. Three committed binaries +// with no way to regenerate them is how an icon becomes something nobody dares touch; +// here the SVG is the source and everything else is output, so changing the mark is +// changing one file and running this. +// +// npm run icons +// +// Needs `rsvg-convert` (brew install librsvg). The .icns additionally needs `iconutil`, +// which only exists on macOS — on Linux that step is skipped with a warning, because CI +// builds Linux and Windows there and the committed .icns is what a mac build uses. + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +const ROOT = path.resolve(import.meta.dirname, '..') +const RESOURCES = path.join(ROOT, 'resources') +const SOURCE = path.join(RESOURCES, 'icon.svg') + +/** Windows wants these; anything larger than 256 cannot go in an ICO as PNG anyway. */ +const ICO_SIZES = [16, 24, 32, 48, 64, 128, 256] + +/** What macOS asks for in an iconset, with the @2x names it insists on. */ +const ICNS_ENTRIES = [ + [16, 'icon_16x16.png'], [32, 'icon_16x16@2x.png'], + [32, 'icon_32x32.png'], [64, 'icon_32x32@2x.png'], + [128, 'icon_128x128.png'], [256, 'icon_128x128@2x.png'], + [256, 'icon_256x256.png'], [512, 'icon_256x256@2x.png'], + [512, 'icon_512x512.png'], [1024, 'icon_512x512@2x.png'] +] + +function render (size, target) { + execFileSync('rsvg-convert', ['-w', String(size), '-h', String(size), SOURCE, '-o', target]) +} + +/** + * An ICO holding PNGs. + * + * The format allows it since Vista and every tool this project's packages reach has + * supported it for longer than that. Writing the container by hand is a few lines and + * saves a dependency on ImageMagick, which is not installed here and is not worth + * making a build requirement for 22 bytes of header per image. + */ +function writeIco (pngs, target) { + const header = Buffer.alloc(6) + header.writeUInt16LE(0, 0) + header.writeUInt16LE(1, 2) // 1 = icon + header.writeUInt16LE(pngs.length, 4) + + const directory = Buffer.alloc(16 * pngs.length) + let offset = header.length + directory.length + + pngs.forEach(({ size, data }, index) => { + const at = index * 16 + // 0 means 256 in this field, which is the whole reason 256 is the largest size here. + directory.writeUInt8(size >= 256 ? 0 : size, at) + directory.writeUInt8(size >= 256 ? 0 : size, at + 1) + directory.writeUInt8(0, at + 2) // palette: none + directory.writeUInt8(0, at + 3) // reserved + directory.writeUInt16LE(1, at + 4) // colour planes + directory.writeUInt16LE(32, at + 6) // bits per pixel + directory.writeUInt32LE(data.length, at + 8) + directory.writeUInt32LE(offset, at + 12) + offset += data.length + }) + + fs.writeFileSync(target, Buffer.concat([header, directory, ...pngs.map((p) => p.data)])) +} + +function buildIco () { + const temporary = fs.mkdtempSync(path.join(RESOURCES, '.ico-')) + try { + const pngs = ICO_SIZES.map((size) => { + const file = path.join(temporary, `${size}.png`) + render(size, file) + return { size, data: fs.readFileSync(file) } + }) + writeIco(pngs, path.join(RESOURCES, 'icon.ico')) + console.log(` icon.ico ${ICO_SIZES.join(', ')}`) + } finally { + fs.rmSync(temporary, { recursive: true, force: true }) + } +} + +function buildIcns () { + const iconset = path.join(RESOURCES, 'icon.iconset') + fs.rmSync(iconset, { recursive: true, force: true }) + fs.mkdirSync(iconset) + try { + for (const [size, name] of ICNS_ENTRIES) render(size, path.join(iconset, name)) + execFileSync('iconutil', ['-c', 'icns', iconset, '-o', path.join(RESOURCES, 'icon.icns')]) + console.log(' icon.icns 16 … 512@2x') + } finally { + fs.rmSync(iconset, { recursive: true, force: true }) + } +} + +/** + * Linux takes a directory of sizes; electron-builder reads whatever is in it. + * + * Named `x.png`, which is the convention it expects and also what a + * `.desktop` entry's icon lookup walks. + */ +function buildLinuxIcons () { + const directory = path.join(RESOURCES, 'icons') + fs.rmSync(directory, { recursive: true, force: true }) + fs.mkdirSync(directory) + const sizes = [16, 32, 48, 64, 128, 256, 512, 1024] + for (const size of sizes) render(size, path.join(directory, `${size}x${size}.png`)) + console.log(` icons/ ${sizes.join(', ')}`) +} + +function main () { + if (!fs.existsSync(SOURCE)) { + console.error(`no ${path.relative(ROOT, SOURCE)} — the icon source is missing`) + process.exit(1) + } + try { + execFileSync('rsvg-convert', ['--version'], { stdio: 'ignore' }) + } catch { + console.error('rsvg-convert is not installed (brew install librsvg / apt install librsvg2-bin)') + process.exit(1) + } + + console.log('rendering icons from resources/icon.svg') + render(1024, path.join(RESOURCES, 'icon.png')) + console.log(' icon.png 1024') + buildLinuxIcons() + buildIco() + + if (process.platform === 'darwin') { + buildIcns() + } else { + // Not fatal: the committed .icns is what a mac build uses, and only a Mac can make + // one. Saying so is better than a build that quietly ships the Electron default. + console.warn(' icon.icns skipped — iconutil is macOS only; the committed one stands') + } +} + +main() diff --git a/src/main/MainWindowFactory.ts b/src/main/MainWindowFactory.ts index 904d40c..cf1f25e 100644 --- a/src/main/MainWindowFactory.ts +++ b/src/main/MainWindowFactory.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { BrowserWindow, shell, type BrowserWindowConstructorOptions } from 'electron' +import { app, BrowserWindow, shell, type BrowserWindowConstructorOptions } from 'electron' const WINDOW_OPTIONS: BrowserWindowConstructorOptions = { width: 1040, @@ -10,6 +10,22 @@ const WINDOW_OPTIONS: BrowserWindowConstructorOptions = { title: 'WarpEngine Client' } +/** + * The window icon, in development only. + * + * A packaged app carries its icon in the bundle, the .exe and the .desktop entry, and + * `resources/` is not inside the package at all — pointing at it there would be a path + * that does not exist. Run from source there is nothing to carry the icon, so the + * window and the taskbar show Electron's own, which makes a dev run look like a + * different application from the one being built. macOS ignores this either way: the + * Dock icon comes from the bundle. + */ +function developmentIcon (): Pick { + if (app.isPackaged) return {} + + return { icon: path.join(__dirname, '..', '..', 'resources', 'icons', '512x512.png') } +} + /** * The one window. * @@ -23,6 +39,7 @@ export class MainWindowFactory { public createWindow (): BrowserWindow { const window = new BrowserWindow({ ...WINDOW_OPTIONS, + ...developmentIcon(), webPreferences: { preload: path.join(__dirname, '..', 'preload', 'preload.js'), contextIsolation: true,