Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Binary file modified build/icon.icns
Binary file not shown.
80 changes: 40 additions & 40 deletions build/icon.icon/icon.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Binary file modified build/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
120 changes: 120 additions & 0 deletions scripts/dev-run.mjs
Original file line number Diff line number Diff line change
@@ -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)
70 changes: 70 additions & 0 deletions scripts/gen-icon.mjs
Original file line number Diff line number Diff line change
@@ -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 })
}
8 changes: 4 additions & 4 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))

Expand All @@ -16,7 +19,7 @@ async function applyLiquidGlass(handle: Buffer): Promise<void> {
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 */ }
}

Expand Down Expand Up @@ -63,9 +66,6 @@ function createWindow() {
}

app.whenReady().then(() => {
if (process.platform === 'darwin' && !app.isPackaged) {
app.dock?.setIcon(appIcon)
}
registerIpcHandlers()
createWindow()
})
Expand Down
14 changes: 10 additions & 4 deletions src/main/types/electron-liquid-glass.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading