diff --git a/assets/icons/app-icon.ico b/assets/icons/app-icon.ico new file mode 100644 index 0000000..3b194e2 Binary files /dev/null and b/assets/icons/app-icon.ico differ diff --git a/assets/icons/app-icon.png b/assets/icons/app-icon.png new file mode 100644 index 0000000..b10a88d Binary files /dev/null and b/assets/icons/app-icon.png differ diff --git a/bun.lock b/bun.lock index 9568d6d..f0c8f91 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "bun-types": "^1.3.11", "electron": "^41.0.3", "electron-vite": "^5.0.0", + "rcedit": "^5.0.2", "tailwindcss": "^4.2.2", "typescript": "^5.9.3", "typescript-language-server": "^5.1.3", @@ -133,6 +134,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.43", "", {}, "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="], @@ -305,6 +308,10 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cross-spawn-windows-exe": ["cross-spawn-windows-exe@1.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^1.1.0", "is-wsl": "^2.2.0", "which": "^2.0.2" } }, "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -413,10 +420,16 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -587,6 +600,8 @@ "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -607,6 +622,8 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "rcedit": ["rcedit@5.0.2", "", { "dependencies": { "cross-spawn-windows-exe": "^1.1.0" } }, "sha512-dgysxaeXZ4snLpPjn8aVtHvZDCx+aRcvZbaWBgl1poU6OPustMvOkj9a9ZqASQ6i5Y5szJ13LSvglEOwrmgUxA=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -643,6 +660,10 @@ "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], @@ -713,6 +734,8 @@ "vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], diff --git a/package.json b/package.json index 73f7461..f79f1a6 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "bun-types": "^1.3.11", "electron": "^41.0.3", "electron-vite": "^5.0.0", + "rcedit": "^5.0.2", "tailwindcss": "^4.2.2", "typescript": "^5.9.3", "typescript-language-server": "^5.1.3", diff --git a/scripts/package-electron.mjs b/scripts/package-electron.mjs index e362cea..9f64803 100644 --- a/scripts/package-electron.mjs +++ b/scripts/package-electron.mjs @@ -2,6 +2,7 @@ import { access, cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/prom import { constants } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { rcedit } from 'rcedit'; const productName = 'Kopaya'; const sidecarRuntime = 'win-x64'; @@ -9,6 +10,9 @@ const outputDirectoryName = 'win-unpacked'; const scriptDirectory = dirname(fileURLToPath(import.meta.url)); const repositoryRoot = resolve(scriptDirectory, '..'); +const assetDirectory = join(repositoryRoot, 'assets'); +const windowsIconPath = join(assetDirectory, 'icons', 'app-icon.ico'); +const windowIconPath = join(assetDirectory, 'icons', 'app-icon.png'); const electronDistributionDirectory = join(repositoryRoot, 'node_modules', 'electron', 'dist'); const rendererBuildDirectory = join(repositoryRoot, 'dist'); const electronBuildDirectory = join(repositoryRoot, 'dist-electron'); @@ -16,6 +20,7 @@ const publishedSidecarDirectory = join(repositoryRoot, 'dist-sidecar', sidecarRu const outputDirectory = join(repositoryRoot, 'release', outputDirectoryName); const outputResourcesDirectory = join(outputDirectory, 'resources'); const packagedAppDirectory = join(outputResourcesDirectory, 'app'); +const packagedExecutablePath = join(outputDirectory, `${productName}.exe`); async function ensurePathExists(path, label) { try { @@ -88,6 +93,9 @@ async function main() { } await Promise.all([ + ensurePathExists(assetDirectory, 'Application assets'), + ensurePathExists(windowIconPath, 'Window icon'), + ensurePathExists(windowsIconPath, 'Windows application icon'), ensurePathExists(electronDistributionDirectory, 'Electron runtime'), ensurePathExists(rendererBuildDirectory, 'Renderer build output'), ensurePathExists(electronBuildDirectory, 'Electron build output'), @@ -99,16 +107,18 @@ async function main() { await rm(outputDirectory, { recursive: true, force: true }); await mkdir(outputDirectory, { recursive: true }); await cp(electronDistributionDirectory, outputDirectory, { recursive: true }); - await rename(join(outputDirectory, 'electron.exe'), join(outputDirectory, `${productName}.exe`)); + await rename(join(outputDirectory, 'electron.exe'), packagedExecutablePath); await mkdir(packagedAppDirectory, { recursive: true }); await Promise.all([ writePackagedManifest(), + cp(assetDirectory, join(packagedAppDirectory, 'assets'), { recursive: true }), cp(rendererBuildDirectory, join(packagedAppDirectory, 'dist'), { recursive: true }), cp(electronBuildDirectory, join(packagedAppDirectory, 'dist-electron'), { recursive: true }), cp(publishedSidecarDirectory, join(outputResourcesDirectory, 'sidecar'), { recursive: true }), ]); await copyRuntimeDependencies(runtimeDependencies); + await rcedit(packagedExecutablePath, { icon: windowsIconPath }); console.log(`Packaged ${productName} to ${outputDirectory}`); console.log(`Bundled ${runtimeDependencies.length} runtime dependencies and the self-contained .NET sidecar.`); diff --git a/src/main/windows/appIcon.ts b/src/main/windows/appIcon.ts new file mode 100644 index 0000000..0a3de91 --- /dev/null +++ b/src/main/windows/appIcon.ts @@ -0,0 +1,17 @@ +import { posix, win32 } from 'node:path'; + +export interface WindowIconPathContext { + readonly appPath: string; + readonly platform: NodeJS.Platform; +} + +function getPathModule(platform: NodeJS.Platform) { + return platform === 'win32' ? win32 : posix; +} + +export function resolveWindowIconPath(context: WindowIconPathContext): string { + const pathModule = getPathModule(context.platform); + const iconFileName = context.platform === 'win32' ? 'app-icon.ico' : 'app-icon.png'; + + return pathModule.join(context.appPath, 'assets', 'icons', iconFileName); +} diff --git a/src/main/windows/createMainWindow.ts b/src/main/windows/createMainWindow.ts index 5b89ce3..7133a5d 100644 --- a/src/main/windows/createMainWindow.ts +++ b/src/main/windows/createMainWindow.ts @@ -1,6 +1,8 @@ -import { BrowserWindow, Menu, shell } from 'electron'; +import { app, BrowserWindow, Menu, shell } from 'electron'; import { join } from 'node:path'; +import { resolveWindowIconPath } from '@main/windows/appIcon'; + export function createMainWindow(): BrowserWindow { Menu.setApplicationMenu(null); @@ -10,6 +12,10 @@ export function createMainWindow(): BrowserWindow { minWidth: 1120, minHeight: 720, title: 'kopaya', + icon: resolveWindowIconPath({ + appPath: app.getAppPath(), + platform: process.platform, + }), backgroundColor: '#09090b', titleBarStyle: 'hidden', titleBarOverlay: { diff --git a/tests/main/appIcon.test.ts b/tests/main/appIcon.test.ts new file mode 100644 index 0000000..aa19d6d --- /dev/null +++ b/tests/main/appIcon.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test'; + +import { resolveWindowIconPath } from '@main/windows/appIcon'; + +describe('resolveWindowIconPath', () => { + test('uses the Windows ico asset from the packaged app assets directory', () => { + expect( + resolveWindowIconPath({ + appPath: 'C:\\workspace\\personal\\repositories\\kopaya', + platform: 'win32', + }), + ).toBe('C:\\workspace\\personal\\repositories\\kopaya\\assets\\icons\\app-icon.ico'); + }); + + test('uses the PNG asset on non-Windows platforms', () => { + expect( + resolveWindowIconPath({ + appPath: '/Applications/Kopaya.app/Contents/Resources/app', + platform: 'darwin', + }), + ).toBe('/Applications/Kopaya.app/Contents/Resources/app/assets/icons/app-icon.png'); + }); +});