diff --git a/.gitignore b/.gitignore index 2954e05..4987cc6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ cli/bun.lock # Pen.dev local design file (binary, not for repo) design.pen + +# Generated by scripts/gen-icon.mjs (actool compile of build/icon.icon) +build/Assets.car diff --git a/build/icon.icns b/build/icon.icns index 4a4d874..487c47a 100644 Binary files a/build/icon.icns and b/build/icon.icns differ diff --git a/build/icon.icon/icon.json b/build/icon.icon/icon.json index 9526f4b..e59d8ae 100644 --- a/build/icon.icon/icon.json +++ b/build/icon.icon/icon.json @@ -1,69 +1,69 @@ { - "fill" : { - "solid" : "srgb:0.06275,0.09412,0.12549,1.00000" + "fill": { + "solid": "srgb:0.07059,0.07059,0.07843,1.00000" }, - "groups" : [ + "groups": [ { - "blur-material" : null, - "layers" : [ + "blur-material": null, + "layers": [ { - "glass" : true, - "hidden" : false, - "image-name" : "hoist-hook.png", - "name" : "hoist-hook", - "position" : { - "scale" : 1, - "translation-in-points" : [ + "glass": true, + "hidden": false, + "image-name": "hoist-hook.png", + "name": "hoist-hook", + "position": { + "scale": 1, + "translation-in-points": [ 0, 0 ] } } ], - "lighting" : "individual", - "shadow" : { - "kind" : "neutral", - "opacity" : 0.5 + "lighting": "individual", + "shadow": { + "kind": "neutral", + "opacity": 0.5 }, - "specular" : true, - "translucency" : { - "enabled" : true, - "value" : 0.5 + "specular": true, + "translucency": { + "enabled": true, + "value": 0.5 } }, { - "blur-material" : null, - "layers" : [ + "blur-material": null, + "layers": [ { - "glass" : true, - "hidden" : false, - "image-name" : "hoist-housing.png", - "name" : "hoist-housing", - "position" : { - "scale" : 1, - "translation-in-points" : [ + "glass": true, + "hidden": false, + "image-name": "hoist-housing.png", + "name": "hoist-housing", + "position": { + "scale": 1, + "translation-in-points": [ 0, 0 ] } } ], - "lighting" : "individual", - "shadow" : { - "kind" : "neutral", - "opacity" : 0.5 + "lighting": "individual", + "shadow": { + "kind": "neutral", + "opacity": 0.5 }, - "specular" : true, - "translucency" : { - "enabled" : true, - "value" : 0.5 + "specular": true, + "translucency": { + "enabled": true, + "value": 0.5 } } ], - "supported-platforms" : { - "circles" : [ + "supported-platforms": { + "circles": [ "watchOS" ], - "squares" : "shared" + "squares": "shared" } } diff --git a/build/icon.png b/build/icon.png index 401ad95..b3cd442 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/package.json b/package.json index cf816af..92f2c19 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "main": "dist/main/index.js", "scripts": { "dev": "concurrently -k \"npm run dev:main\" \"npm run dev:renderer\"", - "dev:main": "tsc -p tsconfig.node.json && electron dist/main/index.js", + "dev:main": "tsc -p tsconfig.node.json && npm run build:preload && node scripts/dev-run.mjs", "dev:renderer": "vite", "build": "npm run build:main && npm run build:preload && npm run build:renderer", "build:main": "tsc -p tsconfig.node.json", @@ -18,6 +18,7 @@ "lint": "eslint src --ext .ts,.tsx", "typecheck": "tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.json --noEmit", "gen:catalog": "node scripts/gen-catalog.mjs", + "gen:icon": "node scripts/gen-icon.mjs", "prebuild": "npm run gen:catalog", "prebuild:renderer": "npm run gen:catalog", "catalog:check": "node scripts/check-catalog-stale.mjs", diff --git a/scripts/dev-run.mjs b/scripts/dev-run.mjs new file mode 100644 index 0000000..7031db7 --- /dev/null +++ b/scripts/dev-run.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env -S node --no-warnings +// Dev launcher: runs the app through a Hoist-branded copy of the Electron +// bundle on macOS so the dock shows the "Hoist" name and the liquid glass +// app icon (compiled Assets.car from build/icon.icon) instead of the stock +// Electron name/icon. Other platforms launch the stock Electron binary. +// +// The branded bundle lives in node_modules/.hoist-dev/Hoist.app and is +// rebuilt when Electron changes. Run `npm run gen:icon` first after editing +// build/icon.icon so build/Assets.car and build/icon.icns are fresh. +'use strict' + +import { spawnSync } from 'node:child_process' +import { copyFileSync, cpSync, existsSync, linkSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const electronBin = createRequire(import.meta.url)('electron') + +function ensureDevBundle() { + const src = resolve(ROOT, 'node_modules/electron/dist/Electron.app') + const dest = resolve(ROOT, 'node_modules/.hoist-dev/Hoist.app') + const srcPlist = resolve(src, 'Contents/Info.plist') + const marker = resolve(dest, 'Contents/.hoist-src-mtime') + const mtime = String(statSync(srcPlist).mtimeMs) + if (existsSync(marker) && readFileSync(marker, 'utf8') === mtime) { + return resolve(dest, 'Contents/MacOS/Electron') + } + + rmSync(resolve(dest, '..'), { recursive: true, force: true }) + cpSync(src, dest, { recursive: true, verbatimSymlinks: true }) + + const plist = resolve(dest, 'Contents/Info.plist') + const pb = (args) => { + const r = spawnSync('/usr/libexec/PlistBuddy', args, { encoding: 'utf8' }) + if (r.status !== 0) throw new Error(`PlistBuddy ${args[1]} failed: ${r.stderr}`) + } + pb(['-c', 'Set :CFBundleName Hoist', plist]) + pb(['-c', 'Set :CFBundleDisplayName Hoist', plist]) + pb(['-c', 'Set :CFBundleIconFile icon.icns', plist]) + pb(['-c', 'Add :CFBundleIconName string Icon', plist]) + + const res = resolve(dest, 'Contents/Resources') + mkdirSync(res, { recursive: true }) + const car = resolve(ROOT, 'build/Assets.car') + const icns = resolve(ROOT, 'build/icon.icns') + if (existsSync(car)) copyFileSync(car, resolve(res, 'Assets.car')) + if (existsSync(icns)) copyFileSync(icns, resolve(res, 'icon.icns')) + + // Info.plist edits invalidate the stock signature; ad-hoc re-sign so + // arm64 macOS will still launch it. + const sign = spawnSync('codesign', ['--force', '--deep', '--sign', '-', dest], { encoding: 'utf8' }) + if (sign.status !== 0) throw new Error(`codesign failed: ${sign.stderr}`) + spawnSync('/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister', ['-f', dest]) + + writeFileSync(marker, mtime) + console.log('✓ dev bundle ready: node_modules/.hoist-dev/Hoist.app') + return resolve(dest, 'Contents/MacOS/Electron') +} + +function ensureRenamedBinary() { + const distDir = resolve(ROOT, 'node_modules/electron/dist') + if (process.platform === 'win32') { + const src = resolve(distDir, 'electron.exe') + const dest = resolve(distDir, 'Hoist.exe') + if (!existsSync(dest) || statSync(dest).mtimeMs < statSync(src).mtimeMs) { + copyFileSync(src, dest) + // Patch version strings so taskbar/alt-tab show "Hoist" instead of + // the Electron FileDescription. rcedit ships with electron-winstaller. + const rcedit = resolve(ROOT, 'node_modules/electron-winstaller/vendor/rcedit.exe') + if (existsSync(rcedit)) { + const r = spawnSync(rcedit, [ + dest, + '--set-version-string', 'FileDescription', 'Hoist', + '--set-version-string', 'ProductName', 'Hoist', + '--set-icon', resolve(ROOT, 'build/icon.ico'), + ], { encoding: 'utf8' }) + if (r.status !== 0) console.error('rcedit failed (non-fatal):', r.stderr) + } + console.log('✓ dev binary: node_modules/electron/dist/Hoist.exe') + } + return dest + } + if (process.platform === 'linux') { + const src = resolve(distDir, 'electron') + const dest = resolve(distDir, 'hoist') + if (!existsSync(dest) || statSync(dest).mtimeMs < statSync(src).mtimeMs) { + rmSync(dest, { force: true }) + linkSync(src, dest) + console.log('✓ dev binary: node_modules/electron/dist/hoist') + } + return dest + } + return electronBin +} + +let bin = electronBin +if (process.platform === 'darwin') { + try { + bin = ensureDevBundle() + } catch (err) { + console.error('dev bundle setup failed, falling back to stock Electron:', err) + } +} else { + // win32/linux: the stock binary is named "electron(.exe)", which leaks + // into alt-tab / taskbar / system monitors. Drop a renamed copy/hardlink + // next to it (same dir, so resources still resolve) and launch that. + try { + bin = ensureRenamedBinary() + } catch (err) { + console.error('binary rename failed, falling back to stock Electron:', err) + } +} + +const child = spawnSync(bin, [resolve(ROOT, 'dist/main/index.js'), ...process.argv.slice(2)], { + stdio: 'inherit', + env: process.env, +}) +process.exit(child.status ?? 1) diff --git a/scripts/gen-icon.mjs b/scripts/gen-icon.mjs new file mode 100644 index 0000000..7530423 --- /dev/null +++ b/scripts/gen-icon.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env -S node --no-warnings +// Regenerate build/icon.icns and build/icon.png from build/icon.icon +// (the Icon Composer / liquid glass source of truth). +// +// Requires macOS with Xcode 26+ (actool) for the .icns, and ImageMagick +// (`magick`) for the flattened .png used by the dev dock icon and win/linux. +// Run manually after editing build/icon.icon: `npm run gen:icon`. +'use strict' + +import { spawnSync } from 'node:child_process' +import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const ICON = resolve(ROOT, 'build/icon.icon') +const tmp = mkdtempSync(resolve(tmpdir(), 'hoist-icon-')) + +try { + if (!existsSync(ICON)) throw new Error(`missing ${ICON}`) + + // actool: compile the Icon Composer bundle -> Icon.icns (+ Assets.car) + // The input dir must be named Icon.icon for --app-icon Icon to match. + const out = resolve(tmp, 'out') + const iconIn = resolve(tmp, 'Icon.icon') + cpSync(ICON, iconIn, { recursive: true }) + mkdirSync(out, { recursive: true }) + const actool = spawnSync('actool', [ + iconIn, '--compile', out, + '--output-format', 'human-readable-text', + '--output-partial-info-plist', resolve(out, 'info.plist'), + '--app-icon', 'Icon', '--include-all-app-icons', + '--enable-on-demand-resources', 'NO', + '--development-region', 'en', + '--target-device', 'mac', + '--minimum-deployment-target', '26.0', + '--platform', 'macosx', + ], { encoding: 'utf8' }) + if (actool.status !== 0) { + throw new Error(`actool failed (needs Xcode 26+):\n${actool.stdout}\n${actool.stderr}`) + } + copyFileSync(resolve(out, 'Icon.icns'), resolve(ROOT, 'build/icon.icns')) + console.log('✓ build/icon.icns') + if (existsSync(resolve(out, 'Assets.car'))) { + copyFileSync(resolve(out, 'Assets.car'), resolve(ROOT, 'build/Assets.car')) + console.log('✓ build/Assets.car (used by the dev app bundle)') + } + + // magick: flatten fill + layers (bottom-to-top) -> icon.png + const spec = JSON.parse(readFileSync(resolve(ICON, 'icon.json'), 'utf8')) + const [r, g, b] = spec.fill.solid.replace('srgb:', '').split(',').map(Number) + const hex = (n) => Math.round(n * 255).toString(16).padStart(2, '0') + const bg = `#${hex(r)}${hex(g)}${hex(b)}` + const layers = spec.groups + .flatMap((grp) => grp.layers) + .filter((l) => !l.hidden) + .reverse() + .map((l) => resolve(ICON, 'Assets', l['image-name'])) + const png = resolve(ROOT, 'build/icon.png') + const magick = spawnSync('magick', [ + '-size', '1024x1024', `xc:${bg}`, + ...layers.flatMap((p) => [p, '-composite']), + png, + ], { encoding: 'utf8' }) + if (magick.status !== 0) throw new Error(`magick failed:\n${magick.stderr}`) + console.log('✓ build/icon.png') +} finally { + rmSync(tmp, { recursive: true, force: true }) +} diff --git a/src/main/index.ts b/src/main/index.ts index 3f141f9..340bf12 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,9 @@ import { registerIpcHandlers } from './ipc' let mainWindow: BrowserWindow | null = null app.setName('Hoist') +if (process.platform === 'win32') { + app.setAppUserModelId('app.hoist') +} const appIcon = nativeImage.createFromPath(join(__dirname, '../../build/icon.png')) @@ -16,7 +19,7 @@ async function applyLiquidGlass(handle: Buffer): Promise { if (process.platform !== 'darwin') return try { const { default: liquidGlass } = await import('electron-liquid-glass') - liquidGlass(handle, { cornerRadius: 12, tintColor: '#12121459' }) + liquidGlass.addView(handle, { cornerRadius: 12, tintColor: '#12121459' }) } catch { /* glass unsupported on this macOS version — window stays transparent */ } } @@ -63,9 +66,6 @@ function createWindow() { } app.whenReady().then(() => { - if (process.platform === 'darwin' && !app.isPackaged) { - app.dock?.setIcon(appIcon) - } registerIpcHandlers() createWindow() }) diff --git a/src/main/types/electron-liquid-glass.d.ts b/src/main/types/electron-liquid-glass.d.ts index c3f8d59..3e02a1c 100644 --- a/src/main/types/electron-liquid-glass.d.ts +++ b/src/main/types/electron-liquid-glass.d.ts @@ -2,9 +2,15 @@ declare module 'electron-liquid-glass' { export interface LiquidGlassOptions { cornerRadius?: number tintColor?: string + opaque?: boolean } - export default function addView( - nativeWindowHandle: Buffer, - options?: LiquidGlassOptions, - ): void + export interface LiquidGlass { + isGlassSupported(): boolean + addView(nativeWindowHandle: Buffer, options?: LiquidGlassOptions): number + unstable_setVariant(id: number, variant: number): void + unstable_setScrim(id: number, scrim: number): void + unstable_setSubdued(id: number, subdued: number): void + } + const liquidGlass: LiquidGlass + export default liquidGlass } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 929c4df..6fbe35e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -377,8 +377,8 @@ export function App() { ? cur : (harnesses.find((e) => e.primary)?.id ?? harnesses[0]?.id ?? cur) }) - } catch { - // ignore + } catch (err) { + console.error('[hoist] library.list failed:', err) } }, []) @@ -411,7 +411,8 @@ export function App() { if (cur && rows.some((r) => r.secretId === cur)) return cur return rows[0]?.secretId ?? null }) - } catch { + } catch (err) { + console.error('[hoist] refreshKeys failed:', err) setKeys([]) } }, []) @@ -423,7 +424,8 @@ export function App() { setSelectedGatewayId((cur) => ( list.some((g) => g.id === cur) ? cur : (list[0]?.id ?? cur) )) - } catch { + } catch (err) { + console.error('[hoist] gateway.list failed:', err) setGateways([]) } }, [])