diff --git a/apps/desktop/scripts/patch-dev-protocol.ts b/apps/desktop/scripts/patch-dev-protocol.ts index b1a8586104d..141149bf55b 100644 --- a/apps/desktop/scripts/patch-dev-protocol.ts +++ b/apps/desktop/scripts/patch-dev-protocol.ts @@ -10,7 +10,17 @@ */ import { execSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { + existsSync, + lstatSync, + readFileSync, + readlinkSync, + renameSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, relative, resolve, sep } from "node:path"; import { config } from "dotenv"; @@ -57,10 +67,11 @@ if (!workspaceName) { } const PROTOCOL_SCHEME = `superset-${workspaceName}`; const BUNDLE_ID = `com.superset.desktop.${workspaceName}`; -const ELECTRON_APP_PATH = resolve( +const ELECTRON_DIST_DIR = resolve( import.meta.dirname, - "../node_modules/electron/dist/Electron.app", + "../node_modules/electron/dist", ); +const ELECTRON_APP_PATH = resolve(ELECTRON_DIST_DIR, "Electron.app"); const PLIST_PATH = resolve(ELECTRON_APP_PATH, "Contents/Info.plist"); if (!existsSync(PLIST_PATH)) { @@ -68,6 +79,8 @@ if (!existsSync(PLIST_PATH)) { process.exit(0); } +const DISPLAY_NAME = `Superset (${workspaceName})`; + try { const currentBundleId = execSync( `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${PLIST_PATH}" 2>/dev/null`, @@ -77,8 +90,34 @@ try { `/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes:0:CFBundleURLSchemes:0" "${PLIST_PATH}" 2>/dev/null`, { encoding: "utf-8" }, ).trim(); + const currentName = execSync( + `/usr/libexec/PlistBuddy -c "Print :CFBundleName" "${PLIST_PATH}" 2>/dev/null`, + { encoding: "utf-8" }, + ).trim(); - if (currentBundleId === BUNDLE_ID && currentScheme === PROTOCOL_SCHEME) { + // Also check if the .app has been renamed and path.txt is updated + const isRenamed = + lstatSync(ELECTRON_APP_PATH).isSymbolicLink() && + readlinkSync(ELECTRON_APP_PATH) === `${DISPLAY_NAME}.app`; + const electronPkgCheck = resolve( + import.meta.dirname, + "../node_modules/electron", + ); + const pathTxtCheck = resolve(electronPkgCheck, "path.txt"); + let pathTxtCorrect = false; + try { + pathTxtCorrect = + readFileSync(pathTxtCheck, "utf-8").trim() === + `${DISPLAY_NAME}.app/Contents/MacOS/Electron`; + } catch {} + + if ( + currentBundleId === BUNDLE_ID && + currentScheme === PROTOCOL_SCHEME && + currentName === DISPLAY_NAME && + isRenamed && + pathTxtCorrect + ) { console.log( `[patch-dev-protocol] ${PROTOCOL_SCHEME}:// already registered`, ); @@ -92,6 +131,21 @@ execSync( `/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${BUNDLE_ID}" "${PLIST_PATH}"`, ); +// CFBundleName exists in default Electron plist, so Set works +execSync( + `/usr/libexec/PlistBuddy -c "Set :CFBundleName ${DISPLAY_NAME}" "${PLIST_PATH}"`, +); + +// CFBundleDisplayName may not exist — delete then add to handle both cases +try { + execSync( + `/usr/libexec/PlistBuddy -c "Delete :CFBundleDisplayName" "${PLIST_PATH}" 2>/dev/null`, + ); +} catch {} +execSync( + `/usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string '${DISPLAY_NAME}'" "${PLIST_PATH}"`, +); + // Remove existing URL types to avoid stale entries from previous patches try { execSync( @@ -112,9 +166,53 @@ for (const cmd of commands) { execSync(`/usr/libexec/PlistBuddy -c "${cmd}" "${PLIST_PATH}"`); } +// Rename Electron.app so macOS uses our display name for the dock label. +// The plist CFBundleName is set correctly, but Electron's runtime overrides +// the in-memory value before the dock reads it. Renaming the .app bundle +// ensures macOS sees the correct name from the bundle directory itself. +// A symlink preserves backward compatibility for the `electron` npm package. +const DESIRED_APP_NAME = `${DISPLAY_NAME}.app`; +const desiredAppPath = resolve(ELECTRON_DIST_DIR, DESIRED_APP_NAME); +let actualAppPath = ELECTRON_APP_PATH; + +try { + const stats = lstatSync(ELECTRON_APP_PATH); + + if (stats.isSymbolicLink()) { + const currentTarget = readlinkSync(ELECTRON_APP_PATH); + if (currentTarget === DESIRED_APP_NAME) { + // Already correctly renamed + actualAppPath = desiredAppPath; + } else { + // Different workspace name from previous run — update + const oldTargetPath = resolve(ELECTRON_DIST_DIR, currentTarget); + unlinkSync(ELECTRON_APP_PATH); + if (existsSync(oldTargetPath)) { + renameSync(oldTargetPath, desiredAppPath); + } + symlinkSync(DESIRED_APP_NAME, ELECTRON_APP_PATH); + actualAppPath = desiredAppPath; + } + } else { + // Real directory — rename and create symlink + if (existsSync(desiredAppPath)) { + rmSync(desiredAppPath, { recursive: true }); + } + renameSync(ELECTRON_APP_PATH, desiredAppPath); + symlinkSync(DESIRED_APP_NAME, ELECTRON_APP_PATH); + actualAppPath = desiredAppPath; + } + + console.log( + `[patch-dev-protocol] Renamed Electron.app to ${DESIRED_APP_NAME}`, + ); +} catch (err) { + console.warn("[patch-dev-protocol] Failed to rename Electron.app:", err); +} + try { execSync( - `/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "${ELECTRON_APP_PATH}"`, + `/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "${actualAppPath}"`, ); console.log( `[patch-dev-protocol] Registered ${PROTOCOL_SCHEME}:// with Launch Services`, @@ -125,3 +223,18 @@ try { err, ); } + +// Update the electron package's path.txt so electron-vite launches from the +// renamed .app directly (not through the Electron.app symlink). This ensures +// the invocation path contains the correct app name for macOS bundle resolution. +const electronPkgDir = resolve(import.meta.dirname, "../node_modules/electron"); +const pathTxtPath = resolve(electronPkgDir, "path.txt"); +const desiredPathTxt = `${DESIRED_APP_NAME}/Contents/MacOS/Electron`; +try { + writeFileSync(pathTxtPath, desiredPathTxt); + console.log( + `[patch-dev-protocol] Updated path.txt to use ${DESIRED_APP_NAME}`, + ); +} catch (err) { + console.warn("[patch-dev-protocol] Failed to update path.txt:", err); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 691fce75dd8..976d45b3982 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -12,6 +12,7 @@ import { getWorkspaceName } from "shared/env.shared"; import { setupAgentHooks } from "./lib/agent-setup"; import { initAppState } from "./lib/app-state"; import { setupAutoUpdater } from "./lib/auto-updater"; +import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { localDb } from "./lib/local-db"; import { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; import { initSentry } from "./lib/sentry"; @@ -21,9 +22,12 @@ import { MainWindow } from "./windows/main"; console.log("[main] Local database ready:", !!localDb); -const workspaceName = getWorkspaceName(); -if (workspaceName) { - app.setName(`Superset (${workspaceName})`); +// Dev mode: label the app with the workspace name so multiple worktrees are distinguishable +if (process.env.NODE_ENV === "development") { + const workspaceName = getWorkspaceName(); + if (workspaceName) { + app.setName(`Superset (${workspaceName})`); + } } // Dev mode: register with execPath + app script so macOS launches Electron with our entry point @@ -230,6 +234,7 @@ if (!gotTheLock) { .protocol.handle("superset-icon", iconProtocolHandler); ensureProjectIconsDir(); + setWorkspaceDockIcon(); initSentry(); await initAppState(); diff --git a/apps/desktop/src/main/lib/dock-icon.ts b/apps/desktop/src/main/lib/dock-icon.ts new file mode 100644 index 00000000000..95e06409f50 --- /dev/null +++ b/apps/desktop/src/main/lib/dock-icon.ts @@ -0,0 +1,260 @@ +import { join } from "node:path"; +import { app, nativeImage } from "electron"; +import { env } from "main/env.main"; +import { getWorkspaceName } from "shared/env.shared"; + +/** + * Generates a deterministic HSL hue from a string seed. + */ +function hashToHue(seed: string): number { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = seed.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + return ((hash % 360) + 360) % 360; +} + +/** + * Converts HSL to RGB (all values 0-255). + */ +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + const sNorm = s / 100; + const lNorm = l / 100; + const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = lNorm - c / 2; + + let r = 0; + let g = 0; + let b = 0; + if (h < 60) { + r = c; + g = x; + } else if (h < 120) { + r = x; + g = c; + } else if (h < 180) { + g = c; + b = x; + } else if (h < 240) { + g = x; + b = c; + } else if (h < 300) { + r = x; + b = c; + } else { + r = c; + b = x; + } + + return [ + Math.round((r + m) * 255), + Math.round((g + m) * 255), + Math.round((b + m) * 255), + ]; +} + +/** + * Gets the path to the app icon PNG. + */ +function getIconPath(): string { + if (app.isPackaged) { + return join( + process.resourcesPath, + "app.asar/resources/build/icons/icon.png", + ); + } + + if (env.NODE_ENV === "development") { + return join(app.getAppPath(), "src/resources/build/icons/icon.png"); + } + + return join(__dirname, "../resources/build/icons/icon.png"); +} + +/** + * Signed distance function for a rounded rectangle. + * Negative = inside, positive = outside, zero = on boundary. + */ +function sdfRoundedRect( + px: number, + py: number, + left: number, + top: number, + right: number, + bottom: number, + radius: number, +): number { + const cx = (left + right) / 2; + const cy = (top + bottom) / 2; + const halfW = (right - left) / 2; + const halfH = (bottom - top) / 2; + + const dx = Math.abs(px - cx) - halfW + radius; + const dy = Math.abs(py - cy) - halfH + radius; + + return ( + Math.sqrt(Math.max(dx, 0) ** 2 + Math.max(dy, 0) ** 2) + + Math.min(Math.max(dx, dy), 0) - + radius + ); +} + +/** + * Finds the bounding box of non-transparent pixels in a bitmap. + */ +function findContentBounds( + bitmap: Buffer, + width: number, + height: number, +): { top: number; left: number; bottom: number; right: number } { + let top = height; + let left = width; + let bottom = 0; + let right = 0; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if ((bitmap[(y * width + x) * 4 + 3] ?? 0) > 10) { + if (y < top) top = y; + if (y > bottom) bottom = y; + if (x < left) left = x; + if (x > right) right = x; + } + } + } + + return { top, left, bottom, right }; +} + +/** + * Draws a rounded-rectangle border on raw RGBA bitmap data. + * The border is drawn inward from the specified bounds. + */ +function drawBorder({ + bitmap, + width, + thickness, + left, + top, + right, + bottom, + cornerRadius, + rgb, +}: { + bitmap: Buffer; + width: number; + thickness: number; + left: number; + top: number; + right: number; + bottom: number; + cornerRadius: number; + rgb: [number, number, number]; +}) { + const innerRadius = Math.max(0, cornerRadius - thickness); + + for (let y = top; y <= bottom; y++) { + for (let x = left; x <= right; x++) { + const outerDist = sdfRoundedRect( + x, + y, + left, + top, + right, + bottom, + cornerRadius, + ); + const innerDist = sdfRoundedRect( + x, + y, + left + thickness, + top + thickness, + right - thickness, + bottom - thickness, + innerRadius, + ); + + // Anti-aliased edges + const outerAlpha = Math.max(0, Math.min(1, 0.5 - outerDist)); + const innerAlpha = Math.max(0, Math.min(1, innerDist + 0.5)); + const borderAlpha = outerAlpha * innerAlpha; + + if (borderAlpha > 0.001) { + const offset = (y * width + x) * 4; + const r = bitmap[offset] ?? 0; + const g = bitmap[offset + 1] ?? 0; + const b = bitmap[offset + 2] ?? 0; + const a = bitmap[offset + 3] ?? 0; + bitmap[offset] = Math.round( + rgb[0] * borderAlpha + r * (1 - borderAlpha), + ); + bitmap[offset + 1] = Math.round( + rgb[1] * borderAlpha + g * (1 - borderAlpha), + ); + bitmap[offset + 2] = Math.round( + rgb[2] * borderAlpha + b * (1 - borderAlpha), + ); + bitmap[offset + 3] = Math.max(a, Math.round(borderAlpha * 255)); + } + } + } +} + +/** + * Sets the macOS dock icon with a colored border based on the workspace name. + * No-op on non-macOS platforms or when no workspace name is set. + */ +export function setWorkspaceDockIcon(): void { + if (process.platform !== "darwin") return; + if (env.NODE_ENV !== "development") return; + + const workspaceName = getWorkspaceName(); + if (!workspaceName) return; + + try { + const iconPath = getIconPath(); + const icon = nativeImage.createFromPath(iconPath); + if (icon.isEmpty()) { + console.warn("[dock-icon] Failed to load icon from:", iconPath); + return; + } + + const size = icon.getSize(); + const bitmap = icon.toBitmap(); + + const hue = hashToHue(workspaceName); + const rgb = hslToRgb(hue, 75, 55); + + // Find the actual icon content area (skip transparent padding) + const bounds = findContentBounds(bitmap, size.width, size.height); + const thickness = Math.round(size.width * 0.038); + const cornerRadius = Math.round(size.width * 0.22); + + // Draw border flush with the content edges, overlapping inward + drawBorder({ + bitmap, + width: size.width, + thickness, + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + cornerRadius, + rgb, + }); + + const newIcon = nativeImage.createFromBitmap(bitmap, { + width: size.width, + height: size.height, + }); + + app.dock?.setIcon(newIcon); + console.log( + `[dock-icon] Set workspace dock icon border with hue ${hue} for "${workspaceName}"`, + ); + } catch (error) { + console.error("[dock-icon] Failed to set dock icon:", error); + } +} diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 65b03ee0ff0..96ea98c409d 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -7,7 +7,10 @@ import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; import { localDb } from "main/lib/local-db"; import { NOTIFICATION_EVENTS, PLATFORM } from "shared/constants"; -import { env } from "shared/env.shared"; +import { + env, + getWorkspaceName as getEnvWorkspaceName, +} from "shared/env.shared"; import type { AgentLifecycleEvent } from "shared/notification-types"; import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; @@ -87,9 +90,15 @@ export async function MainWindow() { const savedWindowState = loadWindowState(); const initialBounds = getInitialWindowBounds(savedWindowState); + const isDev = env.NODE_ENV === "development"; + const workspaceName = isDev ? getEnvWorkspaceName() : undefined; + const windowTitle = workspaceName + ? `${productName} — ${workspaceName}` + : productName; + const window = createWindow({ id: "main", - title: productName, + title: windowTitle, width: initialBounds.width, height: initialBounds.height, x: initialBounds.x,