Skip to content
Open
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
Binary file added build/icon.icns
Binary file not shown.
Binary file added 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.
119 changes: 119 additions & 0 deletions build/make-icon.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Generates a placeholder Kadr app icon (build/icon.png) — a white "K" on a
// rounded-rect gradient, drawn by hand so no image tooling is required.
// Run: node build/make-icon.mjs (then iconutil builds the .icns)
import { deflateSync } from 'zlib'
import { writeFileSync, rmSync, mkdirSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import { execFileSync } from 'child_process'

const S = 1024
const here = dirname(fileURLToPath(import.meta.url))

// --- tiny geometry helpers ---
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x)
// signed distance from point to segment a->b
function segDist(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay
const t = clamp01(((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy || 1))
const cx = ax + t * dx, cy = ay + t * dy
return Math.hypot(px - cx, py - cy)
}
function roundRectInside(px, py, x0, y0, x1, y1, r) {
// returns true if (px,py) is inside the rounded rect
const qx = Math.max(x0 + r - px, px - (x1 - r), 0)
const qy = Math.max(y0 + r - py, py - (y1 - r), 0)
if (px < x0 || px > x1 || py < y0 || py > y1) return false
return qx * qx + qy * qy <= r * r || (qx === 0 || qy === 0)
}

function mix(a, b, t) {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t)
]
}

const TOP = [0x2d, 0x7d, 0xf6] // blue
const BOT = [0x10, 0x3b, 0x8c] // deeper blue
const INK = [0xff, 0xff, 0xff]

// K strokes (in icon space, with margin)
const m = S * 0.30 // stem left
const top = S * 0.27, bot = S * 0.73
const mid = S * 0.50
const hw = S * 0.052 // half stroke width
const stems = [
[m, top, m, bot], // vertical stem
[m, mid, S * 0.72, top], // upper diagonal
[m, mid, S * 0.72, bot] // lower diagonal
]

// RGBA buffer
const px = Buffer.alloc(S * S * 4)
const margin = S * 0.085, r = S * 0.225
for (let y = 0; y < S; y++) {
for (let x = 0; x < S; x++) {
const i = (y * S + x) * 4
const inBg = roundRectInside(x, y, margin, margin, S - margin, S - margin, r)
if (!inBg) { px[i + 3] = 0; continue }
const bg = mix(TOP, BOT, y / S)
// K coverage with ~1.5px antialias
let dK = Infinity
for (const s of stems) dK = Math.min(dK, segDist(x, y, s[0], s[1], s[2], s[3]))
const kCov = clamp01((hw - dK) / 2 + 0.5)
const col = mix(bg, INK, kCov)
px[i] = col[0]; px[i + 1] = col[1]; px[i + 2] = col[2]; px[i + 3] = 255
}
}

// --- minimal PNG encoder (RGBA, filter 0) ---
function crc32(buf) {
let c = ~0
for (let i = 0; i < buf.length; i++) {
c ^= buf[i]
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1))
}
return ~c >>> 0
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length)
const t = Buffer.from(type)
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([t, data])))
return Buffer.concat([len, t, data, crc])
}
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(S, 0); ihdr.writeUInt32BE(S, 4)
ihdr[8] = 8; ihdr[9] = 6 // 8-bit, RGBA
const raw = Buffer.alloc(S * (S * 4 + 1))
for (let y = 0; y < S; y++) {
raw[y * (S * 4 + 1)] = 0
px.copy(raw, y * (S * 4 + 1) + 1, y * S * 4, (y + 1) * S * 4)
}
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0))
])
const out = join(here, 'icon.png')
writeFileSync(out, png)
console.log('wrote', out, png.length, 'bytes')

// Build the .icns (macOS only — sips/iconutil). The base PNGs are downscaled
// from the 1024² master; @2x variants reuse the next size up.
if (process.platform === 'darwin') {
const set = join(here, 'icon.iconset')
rmSync(set, { recursive: true, force: true })
mkdirSync(set)
const sips = (sz, name) =>
execFileSync('sips', ['-z', String(sz), String(sz), out, '--out', join(set, name)], { stdio: 'ignore' })
for (const s of [16, 32, 128, 256, 512]) {
sips(s, `icon_${s}x${s}.png`)
sips(s * 2, `icon_${s}x${s}@2x.png`)
}
execFileSync('iconutil', ['-c', 'icns', set, '-o', join(here, 'icon.icns')], { stdio: 'inherit' })
rmSync(set, { recursive: true, force: true })
console.log('wrote', join(here, 'icon.icns'))
}
91 changes: 91 additions & 0 deletions docs/superpowers/specs/2026-06-17-kadr-mac-optimization-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Kadr — macOS bring-up, native polish & packaging

**Date:** 2026-06-17
**Target:** Apple Silicon (arm64), macOS · Node v25 host · Electron 31

## Goal

Run Kadr on this Mac, give it native macOS UX (application menu + Mac-correct
GPU/keyboard behaviour), and produce a local, **ad-hoc-signed** `.app`/`.dmg`
with a placeholder icon. No Developer ID / notarization (out of scope).

## Decisions

| Question | Decision |
|---|---|
| Scope | Full Mac polish: run + native menu + packaged bundle |
| Missing deps | Install via Homebrew (already satisfied: ffmpeg 8.1, python3, claude) |
| Node version | Try v25 first — rebuild succeeded, no pin needed |
| Code signing | Local unsigned → ad-hoc `codesign -s -` (runs on this Mac) |
| App icon | Generated placeholder ("K", `build/make-icon.mjs`) |
| Packaging tool | electron-builder (best fit for the electron-vite `out/` layout) |
| asar | **Disabled** — the app spawns `node electron/mcp-bridge.cjs` and `python3 scripts/transcribe.py` by absolute path; unpacked = zero path rewriting |

## Environment gotchas (not project bugs)

1. **Electron binary download timed out** from GitHub. Fix: build/install with
`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/` (and
`ELECTRON_BUILDER_BINARIES_MIRROR` for packaging). Recorded because anyone
on a GitHub-throttled network will hit this.
2. **`ELECTRON_RUN_AS_NODE=1` leaks** from the VSCode/Claude Code host, which
makes Kadr's Electron run as plain Node and crash at
`protocol.registerSchemesAsPrivileged`. Launch with
`env -u ELECTRON_RUN_AS_NODE`. Does not affect normal launches from Finder.
3. **Finder-launched apps get a minimal PATH** (`/usr/bin:/bin:/usr/sbin:
/sbin`) — no Homebrew / `~/.local/bin` / nvm. In the packaged app this made
the Claude PTY exit instantly ("session ended") because `claude` wasn't on
PATH, and would equally have broken `node` (MCP bridge), ffmpeg/ffprobe and
python3. Fixed in code (see Phase 2 → `fixUserPath`), not just an env note.

## Changes

### Phase 1 — Bring-up
- `npm install` with the Electron mirror. `postinstall` (`electron-rebuild -f
-w node-pty`) rebuilt the native module cleanly on Node v25 / Electron 31.
- Verified `npm run dev`: editor mounts, `window.kadrEditor` present, RU UI.

### Phase 2 — Mac-native (`electron/`)
- `main.ts`: `fixUserPath()` — on packaged macOS launches, rebuild
`process.env.PATH` from the user's login shell (`$SHELL -ilc`, marker-
delimited) plus Homebrew/`~/.local/bin` fallbacks, before anything spawns.
Fixes the Finder "session ended" Claude bug and unblocks ffmpeg/node/python3.
- `main.ts`: VAAPI hardware-codec switches (`ignore-gpu-blocklist`,
`VaapiVideo*`) are **Linux-only** now — macOS uses native VideoToolbox; the
flags were Linux/Intel-specific and only risked the GPU sandbox off-platform.
- `menu.ts` (new): real macOS application menu. File (New/Open/Save/Save As/
Export) and project Undo/Redo are custom items with `CmdOrCtrl` accelerators
forwarded to the renderer over `menu:command`; clipboard/selection keep
standard roles so text fields + the Claude terminal stay native.
- `main.ts`: `Menu.setApplicationMenu(buildMenu(...))`; removed
`setMenuBarVisibility(false)`.
- `preload.ts` + `shared/types.ts`: `onMenuCommand` IPC bridge.
- `src/App.tsx`: dispatch menu commands to the existing toolbar handlers
(undo/redo defer to native text undo when an input is focused); the in-app
keyboard handler no longer double-binds Save/Undo/Redo (the menu owns them,
fixing the Mac ⌘-key gap since the old handler only checked `ctrlKey`).

### Phase 3 — Packaging
- `build/make-icon.mjs` (new): generates `build/icon.png` + `build/icon.icns`
(geometric "K", no external image tooling). `npm run icon`.
- `electron-builder.yml`: `asar: false`, `npmRebuild: false`, arm64 dmg+zip,
`mac.identity: null`, aux files included in `files`.
- `package.json`: `icon`, `package:mac` scripts; `electron-builder` devDep.
- Build → ad-hoc sign the bundle (`codesign --force --deep -s -`).

## Verification (evidence)

- **Dev:** CDP confirms editor mounts, `kadrEditor`/`onMenuCommand` present.
- **Typecheck:** `npm run typecheck` clean.
- **Packaged `.app`:** launches from `file://`, editor renders, **and the
embedded Claude Code terminal starts inside the bundle** — proving node-pty,
the PTY, and the `claude` spawn all work packaged.
- **DMG:** mounts with `Kadr.app` + `/Applications` symlink, detaches clean.
- **Not automatable here:** native menu *click* (AppleScript blocked by macOS
Accessibility perms, error −1719). Menu build + IPC bridge + clean boot are
verified; the click→action hop is testable manually from the built app.

## Out of scope (YAGNI)

Developer ID / notarization, Windows/Linux packaging, auto-update,
`titleBarStyle: hiddenInset` (the toolbar occupies the top edge where traffic
lights sit — kept the standard title bar), deep perf tuning.
40 changes: 40 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
appId: com.blacktriangle.kadr
productName: Kadr
copyright: Copyright © Black Triangle (GPL-3.0)

# asar is intentionally OFF: the app spawns auxiliary processes by absolute
# path — `node electron/mcp-bridge.cjs` (claude.ts) and `python3
# scripts/transcribe.py` (transcribe.ts), both resolved from app.getAppPath().
# Keeping the app unpacked lets those spawns and their require()s resolve
# from node_modules with zero path rewriting.
asar: false

# node-pty is already rebuilt for Electron 31 (arm64) by the postinstall
# electron-rebuild step, so skip electron-builder's own rebuild (and its
# network round-trip for headers).
npmRebuild: false

directories:
output: dist
buildResources: build

files:
- out/**/*
- electron/mcp-bridge.cjs
- scripts/transcribe.py
- package.json

mac:
category: public.app-category.video
icon: build/icon.icns
# Local, unsigned distribution: no Developer ID. electron-builder applies
# an ad-hoc signature so the arm64 bundle is runnable on this machine.
identity: null
target:
- target: dmg
arch: arm64
- target: zip
arch: arm64

dmg:
title: Kadr ${version}
58 changes: 47 additions & 11 deletions electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { app, BrowserWindow, ipcMain, dialog, protocol } from 'electron'
import { app, BrowserWindow, ipcMain, dialog, protocol, Menu } from 'electron'
import { join, dirname, basename } from 'path'
import { buildMenu } from './menu'
import { promises as fs, createReadStream, statSync } from 'fs'
import { tmpdir } from 'os'
import { tmpdir, homedir } from 'os'
import { execFileSync } from 'child_process'
import { createHash } from 'crypto'
import { probeMedia, makeProxy, ExportMuxer } from './ffmpeg'
import { registerClaudeIpc } from './claude'
Expand All @@ -15,14 +17,48 @@ protocol.registerSchemesAsPrivileged([
{ scheme: 'kadr', privileges: { secure: true, stream: true, supportFetchAPI: true, bypassCSP: true } }
])

// Let Chromium use VAAPI for hardware video encode/decode where the driver
// allows it (Intel iGPU on this machine); WebCodecs then picks it up via
// hardwareAcceleration: 'prefer-hardware'.
app.commandLine.appendSwitch('ignore-gpu-blocklist')
app.commandLine.appendSwitch(
'enable-features',
'VaapiVideoEncoder,VaapiVideoDecoder,VaapiVideoDecodeLinuxGL,AcceleratedVideoEncoder'
)
// A GUI app launched from Finder/LaunchServices inherits only a minimal PATH
// (/usr/bin:/bin:/usr/sbin:/sbin) — none of Homebrew, ~/.local/bin, nvm, etc.
// That silently breaks every external tool the editor shells out to: the
// `claude` CLI (its PTY just exits → "session ended"), `node` for the MCP
// bridge, and ffmpeg/ffprobe/python3. So adopt the user's real login-shell
// PATH before anything spawns. Only needed for packaged macOS launches; a
// dev run already inherits the terminal's environment.
function fixUserPath() {
if (process.platform !== 'darwin' || !app.isPackaged) return
const fallback = [
'/opt/homebrew/bin', '/opt/homebrew/sbin',
'/usr/local/bin', '/usr/local/sbin',
join(homedir(), '.local/bin'),
'/usr/bin', '/bin', '/usr/sbin', '/sbin'
]
let shellPath = ''
try {
const shell = process.env.SHELL || '/bin/zsh'
// login+interactive so ~/.zprofile / ~/.zshrc (nvm, pyenv, custom dirs)
// are sourced; markers isolate $PATH from any shell-startup banner noise.
const out = execFileSync(shell, ['-ilc', 'printf "_KP_<%s>_KP_" "$PATH"'], {
encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore']
})
shellPath = out.match(/_KP_<(.*)>_KP_/)?.[1] ?? ''
} catch { /* shell unavailable — fall back to the known dirs */ }
const parts = [...shellPath.split(':'), ...fallback].filter(Boolean)
process.env.PATH = [...new Set(parts)].join(':')
}
fixUserPath()

// Hardware video encode/decode. On Linux this means VAAPI (Intel/AMD iGPU);
// macOS and Windows already expose their native accelerators (VideoToolbox /
// Media Foundation) to Chromium + WebCodecs without these Linux-only flags,
// and forcing them off-platform only risks the GPU sandbox. WebCodecs then
// picks the hardware path up via hardwareAcceleration: 'prefer-hardware'.
if (process.platform === 'linux') {
app.commandLine.appendSwitch('ignore-gpu-blocklist')
app.commandLine.appendSwitch(
'enable-features',
'VaapiVideoEncoder,VaapiVideoDecoder,VaapiVideoDecodeLinuxGL,AcceleratedVideoEncoder'
)
}

// Last line of defense: a stray async error (e.g. a stream racing a request
// abort) must be logged, not shown as a modal error dialog over the editor.
Expand All @@ -46,7 +82,6 @@ function createWindow() {
sandbox: false
}
})
win.setMenuBarVisibility(false)
if (process.env.ELECTRON_RENDERER_URL) {
win.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
Expand Down Expand Up @@ -134,6 +169,7 @@ app.whenReady().then(() => {
return new Response('not found', { status: 404 })
}
})
Menu.setApplicationMenu(buildMenu(() => win))
registerIpc()
registerClaudeIpc(() => win)
registerTranscribeIpc(() => win)
Expand Down
Loading