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,