1 Commits
Author SHA1 Message Date
mr.zeroandClaude Opus 5 31da869800 The app has an icon
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
Every build so far shipped the default Electron one. electron-builder said so on
every run — "default Electron icon is used, reason=application icon is not set" —
in a line that is very easy to read past. A store people install games with
should not look like a framework demo in the Dock.

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. Drawn for the smallest size first, which is what
settled it — at 32px the W still reads and the lines survive as motion rather
than as noise, where a ring, an outline or fine detail did not. A portal, a play
triangle and a send arrow were each drawn and each discarded: they already mean a
loading spinner, a media player and a submit button.

`resources/icon.svg` is the source and the only file anybody should edit.
`make icons` renders the rest. Three committed binaries with no way to regenerate
them is how an icon becomes something nobody dares change, so the ICO is written
here rather than shelling out to ImageMagick — the container is a header and 16
bytes per image, which is not worth a build dependency this machine does not
have.

`directories.buildResources` had to move off the default: electron-builder looks
in `build/`, which this project uses for compiled output and wipes on `make
clean`, so the icons would have been deleted before every package.

The window picks it up when run from source too, where there is otherwise nothing
to carry an icon and a dev run looks like a different application. Guarded on
`app.isPackaged`, because `resources/` is not inside the package and pointing at
it there would be a path that does not exist.

Verified by reading the icon back out of the built bundle rather than trusting
the config: extracted from `WarpEngine Client.app/Contents/Resources/icon.icns`
and looked at, and the ICO parsed entry by entry — 7 images, 16 to 256, each a
valid PNG.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:14:08 +02:00
19 changed files with 260 additions and 35 deletions
+4 -1
View File
@@ -36,7 +36,7 @@ BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registry
.DEFAULT_GOAL := help .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 dist dist-mac dist-win dist-linux release publish clean distclean version
help: ## List available targets 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 uitest: ## Load the window once and report what rendered
npm run uitest 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) storetest: ## Add and remove a store in a sandbox (the only code that deletes a tree)
npm run storetest npm run storetest
+18
View File
@@ -230,6 +230,7 @@ names. `make` on its own lists everything.
| `make lint` | the strict rule set (`lint-fix` fixes what it can) | | `make lint` | the strict rule set (`lint-fix` fixes what it can) |
| `make check` | **typecheck, lint and every test suite** — the gate | | `make check` | **typecheck, lint and every test suite** — the gate |
| `make start` | run the app against whatever store is installed | | `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 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 | | `make storetest` | add and remove a store in a sandbox — the only code that deletes a tree |
| `SMOKE_HOME=<dir> SMOKE_TOKEN=<bearer> npm run smoke` | the same, against a sandbox store and as a signed-in person | | `SMOKE_HOME=<dir> SMOKE_TOKEN=<bearer> 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 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. 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 ### Continuous integration
`.woodpecker.yaml` builds the **Linux and Windows** packages, and on a tag attaches `.woodpecker.yaml` builds the **Linux and Windows** packages, and on a tag attaches
+18 -25
View File
@@ -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 **The app has an icon.** Until now every build shipped the default Electron one —
a bin. It asks first, and says how many titles will go with it — because removing a `electron-builder` said so on every run, in a line easy to read past: *"default Electron
store *uninstalls what it installed*. That is not a convenience: a store's `state.json` icon is used, reason=application icon is not set"*. A store you install games with
is the only record of which payloads, icons and menu entries belong to it, so leaving should not look like a framework demo in the Dock.
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.
**Adding one moved to a + beside Refresh**, and it now takes a catalog address of your The mark is a **W with three lines running into it**: the product's initial, and what it
own as well as the ones the registry lists. A bare host is enough — `https` is assumed — is doing. It uses the window's own palette, so the icon and the application it opens are
and the name is taken from the address. Both are actions on the whole store rather than the same object. It was drawn for the smallest size first — at 32px the W still reads
on one of them, which is why they sit together; the full-width "Add a store…" button and the lines survive as motion rather than as noise. A portal, a play triangle and a
under the list read as a third store. 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 `resources/icon.svg` is the source and the only file to edit. `make icons` renders the
trap: the grid was replaced and nothing short of installing something brought it back. `.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 The window also picks it up when run from source, where there is otherwise no icon to
sandbox, because removal is the only code here that deletes a directory tree and the carry and a dev run looks like a different application from the one being built.
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.
**`btn-secondary` had no styling at all.** It was introduced in 2.4.0 on the card's Nothing else changed: same store handling, same catalog, same sign-in.
sign-in button and on the sign-in panel, and rendered as a plain button in both places.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "warp-engine-client", "name": "warp-engine-client",
"version": "2.4.0", "version": "2.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "warp-engine-client", "name": "warp-engine-client",
"version": "2.4.0", "version": "2.5.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
+13 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "warp-engine-client", "name": "warp-engine-client",
"productName": "WarpEngine 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.", "description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
"license": "MIT", "license": "MIT",
"author": "Teletype Games <games@teletype.hu>", "author": "Teletype Games <games@teletype.hu>",
@@ -23,7 +23,8 @@
"dist:win": "npm run build && electron-builder --win", "dist:win": "npm run build && electron-builder --win",
"dist:linux": "npm run build && electron-builder --linux", "dist:linux": "npm run build && electron-builder --linux",
"storetest": "npm run build && node build/scripts/StoreLifecycleTest.js", "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": { "devDependencies": {
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
@@ -47,7 +48,8 @@
"dmg", "dmg",
"zip" "zip"
], ],
"artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}" "artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}",
"icon": "resources/icon.icns"
}, },
"dmg": { "dmg": {
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}" "artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
@@ -56,7 +58,8 @@
"target": [ "target": [
"nsis", "nsis",
"portable" "portable"
] ],
"icon": "resources/icon.ico"
}, },
"nsis": { "nsis": {
"artifactName": "WarpEngineClient-Setup-${version}-${arch}.${ext}" "artifactName": "WarpEngineClient-Setup-${version}-${arch}.${ext}"
@@ -69,12 +72,16 @@
"target": [ "target": [
"AppImage", "AppImage",
"deb" "deb"
] ],
"icon": "resources/icons"
}, },
"appImage": { "appImage": {
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}" "artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
}, },
"afterPack": "scripts/after-pack.js" "afterPack": "scripts/after-pack.js",
"directories": {
"buildResources": "resources"
}
}, },
"allowScripts": { "allowScripts": {
"electron@43.4.0": true, "electron@43.4.0": true,
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+44
View File
@@ -0,0 +1,44 @@
<!--
The WarpEngine Client mark.
A W, and three lines running into it. The W is the product's initial; the lines are
what the W is doing — motion, read left to right, which is also why they are dimmer
than it is. Together they say "warp" without a spaceship, a portal or a play triangle,
each of which was tried and each of which turned out to mean something else already:
a send arrow, a loading spinner, a media player.
Drawn for the smallest size first. At 32px the W still reads and the lines survive as
a stack of motion rather than as noise; anything with an outline, a ring or fine
detail did not. The palette is the window's own, taken from src/renderer/style.css,
so the icon and the application it opens are the same object.
This file is the source. `npm run icons` renders every format from it; nothing here is
hand-edited binary.
-->
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1e2937"/>
<stop offset="1" stop-color="#0d1116"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.42" r="0.6">
<stop offset="0" stop-color="#37b98a" stop-opacity="0.24"/>
<stop offset="1" stop-color="#37b98a" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Inset by 7%: a macOS icon is a rounded tile with air around it, and the same
shape is what Windows and the Linux menus get. -->
<rect x="72" y="72" width="880" height="880" rx="200" fill="url(#tile)"/>
<rect x="72" y="72" width="880" height="880" rx="200" fill="url(#glow)"/>
<rect x="72" y="72" width="880" height="880" rx="200" fill="none" stroke="#2a3440" stroke-width="8"/>
<g stroke="#2c8f6c" stroke-width="48" stroke-linecap="round">
<path d="M196 400 H286"/>
<path d="M172 512 H274"/>
<path d="M196 624 H286"/>
</g>
<path d="M366 348 L462 676 L588 456 L714 676 L810 348" fill="none" stroke="#37b98a"
stroke-width="82" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+143
View File
@@ -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 `<size>x<size>.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()
+18 -1
View File
@@ -1,5 +1,5 @@
import path from 'node:path' import path from 'node:path'
import { BrowserWindow, shell, type BrowserWindowConstructorOptions } from 'electron' import { app, BrowserWindow, shell, type BrowserWindowConstructorOptions } from 'electron'
const WINDOW_OPTIONS: BrowserWindowConstructorOptions = { const WINDOW_OPTIONS: BrowserWindowConstructorOptions = {
width: 1040, width: 1040,
@@ -10,6 +10,22 @@ const WINDOW_OPTIONS: BrowserWindowConstructorOptions = {
title: 'WarpEngine Client' 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<BrowserWindowConstructorOptions, 'icon'> {
if (app.isPackaged) return {}
return { icon: path.join(__dirname, '..', '..', 'resources', 'icons', '512x512.png') }
}
/** /**
* The one window. * The one window.
* *
@@ -23,6 +39,7 @@ export class MainWindowFactory {
public createWindow (): BrowserWindow { public createWindow (): BrowserWindow {
const window = new BrowserWindow({ const window = new BrowserWindow({
...WINDOW_OPTIONS, ...WINDOW_OPTIONS,
...developmentIcon(),
webPreferences: { webPreferences: {
preload: path.join(__dirname, '..', 'preload', 'preload.js'), preload: path.join(__dirname, '..', 'preload', 'preload.js'),
contextIsolation: true, contextIsolation: true,