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
18 changes: 18 additions & 0 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@ do not copy private keys, `node_modules`, build output, `.npmrc`, or other ignor
files. Install hooks separately as described below so existing custom hooks are
never silently replaced.

### Worktree Dock labels (macOS)

`just desktop` adds the current branch suffix to the Dock icon in linked Git
worktrees (for example, `person/my-feature` shows `my-feature`). Detached
worktrees use the checkout directory name. Restart the desktop command after
switching or renaming a branch; no Cargo clean is needed.

The launcher reuses the existing badge design and generates an icon under the
ignored `src-tauri/target/dev-icons/` directory. The generated bytes determine its
filename, so changed labels, source artwork, or rendering update Tauri's embedded
icon even with a warm build. Generation requires macOS Swift/AppKit and `iconutil`;
if it fails, startup warns and continues with the ordinary icon. Explicit Tauri
`--config` arguments still take precedence over the generated icon and port.

Ordinary checkouts, non-macOS launches, and `pnpm tauri build` keep their existing
icons. This does not change the app identifier, credentials, profiles, ports, or
notification settings.

## Interactive product iteration

While shaping the first version, default to **edit → human tries the running app
Expand Down
22 changes: 13 additions & 9 deletions scripts/desktop-dev.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { worktreeIcon } from "./worktree-icon.mjs";

const args = process.argv.slice(2);
const forwarded = [];
Expand All @@ -23,18 +25,20 @@ for (; index < args.length && args[index] !== "--"; index++) {
}
}

const config = {};
const icon = worktreeIcon(fileURLToPath(new URL("../", import.meta.url)));
if (icon) config.bundle = { icon: [icon] };
if (port !== undefined) {
// Tauri's own --port controls its static-file server, not our Vite server.
config.build = {
devUrl: `http://localhost:${port}`,
beforeDevCommand: `pnpm dev:desktop --port ${port}`,
};
}
if (Object.keys(config).length) {
// Prepend: Tauri treats everything after a bare positional as runner args.
forwarded.unshift(
"--config",
JSON.stringify({
build: {
devUrl: `http://localhost:${port}`,
beforeDevCommand: `pnpm dev:desktop --port ${port}`,
},
}),
);
// Explicit user configs merge afterward and retain precedence.
forwarded.unshift("--config", JSON.stringify(config));
}
// Runner/application arguments after -- belong to Tauri, including any --port.
forwarded.push(...args.slice(index));
Expand Down
197 changes: 197 additions & 0 deletions scripts/generate-dev-icon.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#!/usr/bin/env swift

import AppKit
import Foundation

// Ported from block/buzz scripts/generate-dev-icon.swift.
// Generate a dev icon with worktree name badge
// Usage: generate-dev-icon.swift <input-icns> <output-icns> <label>

guard CommandLine.arguments.count == 4 else {
fputs("Usage: \(CommandLine.arguments[0]) <input-icns> <output-icns> <label>\n", stderr)
exit(1)
}

let inputPath = CommandLine.arguments[1]
let outputPath = CommandLine.arguments[2]
let label = CommandLine.arguments[3]

guard let iconImage = NSImage(contentsOfFile: inputPath) else {
fputs("Failed to load image: \(inputPath)\n", stderr)
exit(1)
}

// Get the largest representation for best quality
guard let rep = iconImage.representations.max(by: { $0.pixelsWide < $1.pixelsWide }) else {
fputs("No image representations found\n", stderr)
exit(1)
}

let size = NSSize(width: rep.pixelsWide, height: rep.pixelsHigh)

let newImage = NSImage(size: size)
newImage.lockFocus()

// Draw the original icon
iconImage.draw(in: NSRect(origin: .zero, size: size))

// Configure badge
let singleLineHeight = size.height * 0.22
let padding = size.width * 0.03
let maxBadgeWidth = size.width * 0.9

// Text attributes - calculate font size to fit
let maxFontSize = singleLineHeight * 0.65
var fontSize = maxFontSize
var attributes: [NSAttributedString.Key: Any]

// Helper to wrap text on `-` characters
func wrapText(_ text: String, maxWidth: CGFloat, attributes: [NSAttributedString.Key: Any]) -> [String] {
let singleLineSize = (text as NSString).size(withAttributes: attributes)
if singleLineSize.width <= maxWidth {
return [text]
}

// Split on `-` and try to form lines
let parts = text.components(separatedBy: "-")
if parts.count == 1 {
return [text] // No `-` to wrap on
}

var lines: [String] = []
var currentLine = ""

for (index, part) in parts.enumerated() {
let separator = index == 0 ? "" : "-"
let testLine = currentLine.isEmpty ? part : currentLine + separator + part
let testSize = (testLine as NSString).size(withAttributes: attributes)

if testSize.width <= maxWidth || currentLine.isEmpty {
currentLine = testLine
} else {
lines.append(currentLine)
currentLine = part
}
}
if !currentLine.isEmpty {
lines.append(currentLine)
}

return lines
}

// Find font size that fits (allowing up to 2 lines)
var lines: [String] = []
repeat {
attributes = [
.font: NSFont.systemFont(ofSize: fontSize, weight: .bold),
.foregroundColor: NSColor.white
]
lines = wrapText(label, maxWidth: maxBadgeWidth - padding * 4, attributes: attributes)
fontSize -= 1
} while (lines.count > 2 || lines.contains {
($0 as NSString).size(withAttributes: attributes).width > maxBadgeWidth - padding * 4
}) && fontSize > 1

// Calculate text dimensions using typographic metrics
let lineHeight = (lines.first! as NSString).size(withAttributes: attributes).height
let textHeight = lineHeight * CGFloat(lines.count)
let maxLineWidth = lines.map { ($0 as NSString).size(withAttributes: attributes).width }.max() ?? 0

// Badge dimensions based on text
let badgeHeight = textHeight + padding * 2
let cornerRadius = badgeHeight * 0.2
let badgeWidth = maxLineWidth + padding * 4
let badgeX = (size.width - badgeWidth) / 2
let badgeY = size.height - badgeHeight - padding - size.height * 0.05

// Draw badge background (light blue semi-transparent)
let badgePath = NSBezierPath(roundedRect: NSRect(x: badgeX, y: badgeY, width: badgeWidth, height: badgeHeight),
xRadius: cornerRadius, yRadius: cornerRadius)
NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: 0.85).setFill()
badgePath.fill()

// Draw text centered in badge (multiple lines, bottom to top)
for (index, line) in lines.reversed().enumerated() {
let lineSize = (line as NSString).size(withAttributes: attributes)
let textX = badgeX + (badgeWidth - lineSize.width) / 2
let textY = badgeY + padding + lineHeight * CGFloat(index)
(line as NSString).draw(at: NSPoint(x: textX, y: textY), withAttributes: attributes)
}

newImage.unlockFocus()

// First, create PNG data at multiple sizes for icns
guard let tiffData = newImage.tiffRepresentation,
let bitmapRep = NSBitmapImageRep(data: tiffData) else {
fputs("Failed to create bitmap representation\n", stderr)
exit(1)
}

// For simplicity, we'll create a PNG and then use iconutil if available,
// or just save as PNG for the icon (Tauri can use PNG)
guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
fputs("Failed to create PNG data\n", stderr)
exit(1)
}

// If output is .icns, we need to create an iconset and use iconutil
if outputPath.hasSuffix(".icns") {
let tempDir = FileManager.default.temporaryDirectory
let iconsetParent = tempDir.appendingPathComponent(
"staged-dev-\(UUID().uuidString)",
isDirectory: true
)
let iconsetPath = iconsetParent.appendingPathComponent("icon.iconset", isDirectory: true)
try! FileManager.default.createDirectory(at: iconsetPath, withIntermediateDirectories: true)

// Generate all required sizes for iconset
let sizes: [(name: String, size: Int)] = [
("icon_16x16", 16),
("icon_16x16@2x", 32),
("icon_32x32", 32),
("icon_32x32@2x", 64),
("icon_128x128", 128),
("icon_128x128@2x", 256),
("icon_256x256", 256),
("icon_256x256@2x", 512),
("icon_512x512", 512),
("icon_512x512@2x", 1024)
]

for (name, targetSize) in sizes {
let resizedImage = NSImage(size: NSSize(width: targetSize, height: targetSize))
resizedImage.lockFocus()
NSGraphicsContext.current?.imageInterpolation = .high
newImage.draw(in: NSRect(x: 0, y: 0, width: targetSize, height: targetSize))
resizedImage.unlockFocus()

guard let resizedTiff = resizedImage.tiffRepresentation,
let resizedBitmap = NSBitmapImageRep(data: resizedTiff),
let resizedPng = resizedBitmap.representation(using: .png, properties: [:]) else {
continue
}

let filePath = iconsetPath.appendingPathComponent("\(name).png")
try! resizedPng.write(to: filePath)
}

// Use iconutil to create icns
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
process.arguments = ["-c", "icns", iconsetPath.path, "-o", outputPath]
try! process.run()
process.waitUntilExit()

try? FileManager.default.removeItem(at: iconsetParent)

if process.terminationStatus != 0 {
fputs("iconutil failed\n", stderr)
exit(1)
}
} else {
// Just save as PNG
try! pngData.write(to: URL(fileURLWithPath: outputPath))
}

print("Generated: \(outputPath)")
70 changes: 70 additions & 0 deletions scripts/worktree-icon.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
} from "node:fs";
import { basename, join } from "node:path";

// Keep optional macOS artwork out of the shared launcher's argument handling.
export function worktreeIcon(cwd, platform = process.platform) {
if (platform !== "darwin") return null;
const git = (...args) =>
execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
let label;
try {
if (
git("rev-parse", "--path-format=absolute", "--git-dir") ===
git("rev-parse", "--path-format=absolute", "--git-common-dir")
)
return null;
const branch = git("branch", "--show-current");
label = branch
? branch.split("/").at(-1)
: basename(git("rev-parse", "--show-toplevel"));
} catch {
return null;
}

let temporary;
try {
const directory = join(cwd, "src-tauri/target/dev-icons");
mkdirSync(directory, { recursive: true });
temporary = mkdtempSync(join(directory, "staging-"));
const output = join(temporary, "icon.icns");
const result = spawnSync(
"swift",
[
join(cwd, "scripts/generate-dev-icon.swift"),
join(cwd, "src-tauri/icons/icon.icns"),
output,
label,
],
{ cwd, stdio: "inherit" },
);
if (result.error || result.status !== 0)
throw result.error ?? new Error("Icon generator failed");
// Tauri caches the embedded bytes, not the source icon's modification time.
// A content-keyed path changes TAURI_CONFIG and invalidates warm Cargo builds.
const digest = createHash("sha256")
.update(readFileSync(output))
.digest("hex");
const icon = join(directory, `${digest}.icns`);
renameSync(output, icon);
return icon;
} catch {
console.warn(
"Worktree icon could not be generated; using the ordinary Buzz icon.",
);
return null;
} finally {
if (temporary) rmSync(temporary, { recursive: true, force: true });
}
}
4 changes: 4 additions & 0 deletions tests/integration/dev-commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ function recipe(name, ...args) {
new URL("../../scripts/desktop-dev.mjs", import.meta.url),
path.join(directory, "scripts/desktop-dev.mjs"),
);
copyFileSync(
new URL("../../scripts/worktree-icon.mjs", import.meta.url),
path.join(directory, "scripts/worktree-icon.mjs"),
);
// Run the real recipes and adapter, recording only the package-manager boundary.
symlinkSync(process.execPath, path.join(directory, "node"));
writeFileSync(
Expand Down
Loading
Loading