Move goose2 (#8516)

Signed-off-by: Jack Amadeo <jackamadeo@squareup.com>
Co-authored-by: block-open-source[bot] <201011344+block-open-source[bot]@users.noreply.github.com>
Co-authored-by: block-open-source[bot] <1159699+block-open-source[bot]@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: tulsi <tulsi@block.xyz>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: Alex Hancock <alexhancock@block.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Nahiyan Khan <nahiyan.khan@gmail.com>
Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
Jack Amadeo
2026-04-14 10:39:30 -04:00
committed by GitHub
parent 17a404d4f9
commit 482f1962c1
503 changed files with 89093 additions and 105 deletions
+141
View File
@@ -0,0 +1,141 @@
import { readFileSync, readdirSync } from "node:fs";
import { join, relative } from "node:path";
const DEFAULT_LIMIT = 500;
// Add narrowly scoped exceptions here with justification
const EXCEPTIONS = {
"src/features/sidebar/ui/SidebarProjectsSection.tsx": {
limit: 560,
justification:
"Drag-and-drop handlers plus activeProjectId highlight for draft-in-project sessions.",
},
"src/features/chat/ui/ChatView.tsx": {
limit: 535,
justification:
"ACP prewarm guards, project-aware working dir selection, working context sync, and chat bootstrapping still live together here.",
},
"src/features/chat/ui/__tests__/ContextPanel.test.tsx": {
limit: 550,
justification:
"Workspace widget integration tests cover branch switching, worktree creation, dirty-state dialogs, and picker interactions.",
},
"src/features/sidebar/ui/Sidebar.tsx": {
limit: 580,
justification:
"Search-as-you-type filtering and draft-aware sidebar highlight logic.",
},
"src/app/AppShell.tsx": {
limit: 640,
justification:
"Shell still coordinates ACP session loading, project reassignment, and app-level chat routing.",
},
"src/features/chat/hooks/useAcpStream.ts": {
limit: 580,
justification:
"ACP replay, streaming, session binding, model-state event handling, and replay timeout are still centralized here.",
},
"src/features/chat/hooks/__tests__/useAcpStream.test.ts": {
limit: 570,
justification:
"Covers replay buffering, timeout error state, streaming edge cases, and provider identity persistence in one cohesive suite.",
},
"src/features/chat/stores/__tests__/chatSessionStore.test.ts": {
limit: 540,
justification:
"ACP session overlay regressions currently need one broad integration-style store suite.",
},
"src/features/chat/stores/chatSessionStore.ts": {
limit: 640,
justification:
"ACP-backed session overlay persistence, draft migration, and sidebar-facing session merge logic live together for now.",
},
"src-tauri/src/services/acp/manager/dispatcher.rs": {
limit: 540,
justification:
"ACP replay and live-stream event fan-out share one dispatcher with replay event counting for drain stabilisation.",
},
"src-tauri/src/services/acp/manager.rs": {
limit: 630,
justification:
"ACP manager command dispatch loop — export/import/fork session ext_method dispatch adds boilerplate.",
},
"src-tauri/src/services/acp/manager/session_ops.rs": {
limit: 620,
justification:
"Session prepare/load/list logic, working-dir updates, wait_for_replay_drain helper with iteration cap, and composite prepared-session reuse remain colocated while ACP session ownership stabilizes.",
},
};
// Directories excluded from size checks (imported library code)
const EXCLUDED_DIRS = [
"src/shared/ui",
"src/components/ai-elements",
"src/hooks",
];
const DIRS_TO_CHECK = [
{ dir: "src/app", glob: /\.[jt]sx?$/ },
{ dir: "src/features", glob: /\.[jt]sx?$/ },
{ dir: "src/shared", glob: /\.[jt]sx?$/ },
{ dir: "src/components", glob: /\.[jt]sx?$/ },
{ dir: "src/hooks", glob: /\.[jt]sx?$/ },
{ dir: "src-tauri/src", glob: /\.rs$/ },
];
function countLines(filePath) {
const content = readFileSync(filePath, "utf8");
return content.split("\n").length;
}
function isExcluded(filePath) {
const rel = relative(".", filePath);
return EXCLUDED_DIRS.some((dir) => rel.startsWith(dir));
}
function walkDir(dir, pattern) {
const results = [];
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkDir(fullPath, pattern));
} else if (pattern.test(entry.name)) {
results.push(fullPath);
}
}
return results;
}
const violations = [];
for (const { dir, glob } of DIRS_TO_CHECK) {
const files = walkDir(dir, glob);
for (const file of files) {
if (isExcluded(file)) continue;
const rel = relative(".", file);
const limit = EXCEPTIONS[rel]?.limit ?? DEFAULT_LIMIT;
const lines = countLines(file);
if (lines > limit) {
violations.push({ file: rel, lines, limit });
}
}
}
if (violations.length > 0) {
console.error("Desktop file size check failed:");
for (const v of violations) {
console.error(` - ${v.file}: ${v.lines} lines (limit ${v.limit})`);
}
console.error(
"\nSplit the file or add a narrowly scoped exception in `scripts/check-file-sizes.mjs`.",
);
process.exit(1);
} else {
console.log("File size check passed.");
}
+254
View File
@@ -0,0 +1,254 @@
import { readFileSync, readdirSync } from "node:fs";
import { extname, join, relative } from "node:path";
import ts from "typescript";
const CHECKED_PATHS = [
"src/app/ui",
"src/features/agents",
"src/features/chat/ui",
"src/features/home",
"src/features/projects",
"src/features/settings",
"src/features/skills",
"src/features/sidebar",
"src/features/status",
"src/features/sessions",
"src/shared/ui/ai-elements/code-block.tsx",
"src/shared/ui/ai-elements/environment-variables.tsx",
"src/shared/ui/ai-elements/message.tsx",
"src/shared/ui/ai-elements/plan.tsx",
"src/shared/ui/ai-elements/snippet.tsx",
"src/shared/ui/ai-elements/stack-trace.tsx",
"src/shared/ui/ai-elements/terminal.tsx",
"src/shared/ui/ai-elements/context.tsx",
"src/shared/ui/ai-elements/commit.tsx",
];
const EXCLUDED_PATH_SEGMENTS = ["__tests__"];
const EXCLUDED_FILE_MARKERS = [".test.", ".spec."];
const CHECKED_EXTENSIONS = new Set([".ts", ".tsx"]);
const TEXT_ATTRIBUTE_NAMES = new Set([
"aria-label",
"title",
"placeholder",
"alt",
]);
const TEXT_EXCLUDED_TAGS = new Set(["code", "pre", "kbd"]);
const IGNORE_COMMENT = "i18n-check-ignore";
function walkPath(targetPath) {
const entries = [];
let statEntries;
try {
statEntries = readdirSync(targetPath, { withFileTypes: true });
} catch {
if (CHECKED_EXTENSIONS.has(extname(targetPath))) {
return [targetPath];
}
return [];
}
for (const entry of statEntries) {
const fullPath = join(targetPath, entry.name);
if (entry.isDirectory()) {
entries.push(...walkPath(fullPath));
continue;
}
if (CHECKED_EXTENSIONS.has(extname(entry.name))) {
entries.push(fullPath);
}
}
return entries;
}
function isExcluded(filePath) {
const rel = relative(".", filePath);
const normalizedRel = rel.replace(/\\/g, "/");
const pathSegments = normalizedRel.split("/");
return (
EXCLUDED_PATH_SEGMENTS.some((segment) => pathSegments.includes(segment)) ||
EXCLUDED_FILE_MARKERS.some((marker) => normalizedRel.includes(marker))
);
}
function collapseWhitespace(text) {
return text.replace(/\s+/g, " ").trim();
}
function isProbablyUserFacingText(text) {
if (!text) return false;
if (!/\p{L}/u.test(text)) return false;
if (/^https?:\/\//.test(text)) return false;
if (/^[./~@#][\w./-]+$/.test(text)) return false;
if (/^[A-Z0-9_:-]+$/.test(text)) return false;
if (/^[\w.-]+\.[A-Za-z]{2,}$/.test(text)) return false;
return true;
}
function getLineText(sourceText, sourceFile, lineIndex) {
if (lineIndex < 0) return "";
const lineStarts = sourceFile.getLineStarts();
if (lineIndex >= lineStarts.length) return "";
const lineStart = lineStarts[lineIndex];
const lineEnd =
lineIndex + 1 < lineStarts.length
? lineStarts[lineIndex + 1]
: sourceText.length;
return sourceText.slice(lineStart, lineEnd);
}
function hasIgnoreComment(sourceText, sourceFile, node) {
const start = node.getStart(sourceFile);
const { line } = sourceFile.getLineAndCharacterOfPosition(start);
return [line - 1, line].some((lineIndex) =>
getLineText(sourceText, sourceFile, lineIndex).includes(IGNORE_COMMENT),
);
}
function getParentTagName(node) {
if (ts.isJsxElement(node.parent)) {
return node.parent.openingElement.tagName.getText();
}
if (ts.isJsxSelfClosingElement(node.parent)) {
return node.parent.tagName.getText();
}
return null;
}
function normalizeJsxText(text) {
return collapseWhitespace(text);
}
function extractStringFromExpression(expression) {
if (!expression) return null;
if (ts.isStringLiteralLike(expression)) {
return expression.text;
}
if (ts.isNoSubstitutionTemplateLiteral(expression)) {
return expression.text;
}
if (ts.isTemplateExpression(expression)) {
let text = expression.head.text;
for (const span of expression.templateSpans) {
text += `{${span.expression.getText()}}${span.literal.text}`;
}
return text;
}
return null;
}
function formatLocation(sourceFile, position) {
const { line, character } =
sourceFile.getLineAndCharacterOfPosition(position);
return `${sourceFile.fileName}:${line + 1}:${character + 1}`;
}
function collectViolations(filePath) {
const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const violations = [];
function report(node, kind, text) {
const normalizedText = collapseWhitespace(text);
if (!isProbablyUserFacingText(normalizedText)) return;
if (hasIgnoreComment(sourceText, sourceFile, node)) return;
violations.push({
location: formatLocation(sourceFile, node.getStart(sourceFile)),
kind,
text: normalizedText,
});
}
function visit(node) {
if (ts.isJsxText(node)) {
const tagName = getParentTagName(node);
if (tagName && TEXT_EXCLUDED_TAGS.has(tagName)) {
return;
}
const text = normalizeJsxText(node.getText(sourceFile));
report(node, "jsx-text", text);
}
if (ts.isJsxAttribute(node)) {
const attributeName = node.name.text;
if (TEXT_ATTRIBUTE_NAMES.has(attributeName) && node.initializer) {
if (ts.isStringLiteral(node.initializer)) {
report(
node.initializer,
`prop:${attributeName}`,
node.initializer.text,
);
}
if (ts.isJsxExpression(node.initializer)) {
const text = extractStringFromExpression(node.initializer.expression);
if (text) {
report(node.initializer, `prop:${attributeName}`, text);
}
}
}
}
if (ts.isJsxExpression(node) && node.expression) {
if (ts.isJsxAttribute(node.parent)) {
return;
}
const text = extractStringFromExpression(node.expression);
if (text) {
report(node, "jsx-expression", text);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return violations;
}
const files = CHECKED_PATHS.flatMap(walkPath)
.filter((filePath) => !isExcluded(filePath))
.sort();
const violations = files.flatMap((filePath) => collectViolations(filePath));
if (violations.length > 0) {
console.error("i18n string check failed:");
for (const violation of violations) {
console.error(
` - ${violation.location} [${violation.kind}] ${JSON.stringify(violation.text)}`,
);
}
console.error("");
console.error(
`Wrap user-facing strings in translations or annotate a narrow exception with "${IGNORE_COMMENT}".`,
);
console.error(
"The current enforcement scope is intentionally limited to app areas already migrated to i18n.",
);
process.exit(1);
} else {
console.log("i18n string check passed.");
}
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: ensure-local-goose.sh [--print-bin | --check-bin]
Syncs and builds a dedicated local goose checkout for goose2 development.
Environment variables:
GOOSE_DEV_MODE auto|required (default: auto)
GOOSE_DEV_ROOT path to the shared goose2 dev cache root
(default: platform cache dir under home)
GOOSE_DEV_REPO path to the managed goose checkout
(default: $GOOSE_DEV_ROOT/goose)
GOOSE_DEV_STAMP_FILE path to the shared build stamp file
(default: $GOOSE_DEV_ROOT/stamp.env)
GOOSE_DEV_CLONE_URL git clone URL for the managed goose checkout
(default: https://github.com/block/goose.git)
GOOSE_DEV_REMOTE git remote to sync from (default: origin)
GOOSE_DEV_BRANCH preferred branch to use (default: baxen/goose2)
GOOSE_DEV_FALLBACK_BRANCH fallback branch when the preferred branch does
not exist remotely (default: main)
GOOSE_DEV_ALLOW_DIRTY 1 to allow syncing/building a dirty checkout
EOF
}
action="build"
print_bin=0
while [[ $# -gt 0 ]]; do
case "$1" in
--print-bin)
print_bin=1
shift
;;
--check-bin)
action="check"
print_bin=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
mode="${GOOSE_DEV_MODE:-auto}"
clone_url="${GOOSE_DEV_CLONE_URL:-https://github.com/block/goose.git}"
remote="${GOOSE_DEV_REMOTE:-origin}"
preferred_branch="${GOOSE_DEV_BRANCH:-baxen/goose2}"
fallback_branch="${GOOSE_DEV_FALLBACK_BRANCH:-main}"
allow_dirty="${GOOSE_DEV_ALLOW_DIRTY:-0}"
log() {
echo "[goose-dev] $*" >&2
}
fail_or_skip() {
local message="$1"
if [[ "${mode}" == "required" ]]; then
echo "${message}" >&2
exit 1
fi
log "${message}"
# In check mode, exit 2 so callers (e.g. just dev) can detect "not ready"
# and block instead of silently continuing without a goose binary.
if [[ "${action}" == "check" ]]; then
exit 2
fi
exit 0
}
default_goose_dev_root() {
if [[ -n "${XDG_CACHE_HOME:-}" ]]; then
printf '%s/goose2-dev\n' "${XDG_CACHE_HOME}"
return
fi
case "$(uname -s)" in
Darwin)
printf '%s/Library/Caches/goose2-dev\n' "${HOME}"
;;
*)
printf '%s/.cache/goose2-dev\n' "${HOME}"
;;
esac
}
goose_dev_root="${GOOSE_DEV_ROOT:-$(default_goose_dev_root)}"
goose_repo="${GOOSE_DEV_REPO:-${goose_dev_root}/goose}"
stamp_file="${GOOSE_DEV_STAMP_FILE:-${goose_dev_root}/stamp.env}"
bin_path="${goose_repo}/target/debug/goose"
resolve_remote_head() {
local branch_name="$1"
git -C "${goose_repo}" ls-remote --heads "${remote}" "${branch_name}" 2>/dev/null | awk 'NR == 1 { print $1 }'
}
resolve_branch() {
local resolved_branch="${preferred_branch}"
local resolved_head
resolved_head="$(resolve_remote_head "${resolved_branch}")"
if [[ -z "${resolved_head}" && "${resolved_branch}" != "${fallback_branch}" ]]; then
log "Remote branch ${remote}/${resolved_branch} not found; falling back to ${remote}/${fallback_branch}."
resolved_branch="${fallback_branch}"
resolved_head="$(resolve_remote_head "${resolved_branch}")"
fi
if [[ -z "${resolved_head}" ]]; then
if [[ "${mode}" == "required" ]]; then
echo "Could not resolve ${remote}/${resolved_branch} for managed goose checkout at ${goose_repo}." >&2
return 1
fi
log "Could not resolve ${remote}/${resolved_branch} for managed goose checkout at ${goose_repo}."
return 2
fi
RESOLVED_BRANCH="${resolved_branch}"
RESOLVED_REMOTE_HEAD="${resolved_head}"
return 0
}
write_stamp() {
local branch_name="$1"
local commit_sha="$2"
mkdir -p "$(dirname "${stamp_file}")"
{
printf 'STAMP_REPO=%q\n' "${goose_repo}"
printf 'STAMP_BRANCH=%q\n' "${branch_name}"
printf 'STAMP_COMMIT=%q\n' "${commit_sha}"
printf 'STAMP_BIN=%q\n' "${bin_path}"
} >"${stamp_file}"
}
ensure_checkout_exists() {
if [[ -d "${goose_repo}/.git" ]]; then
return 0
fi
if [[ "${action}" == "check" ]]; then
fail_or_skip "Managed goose checkout not found at ${goose_repo}. Rerun just setup."
fi
log "Cloning managed goose checkout into ${goose_repo}."
mkdir -p "$(dirname "${goose_repo}")"
git clone "${clone_url}" "${goose_repo}" >/dev/null 2>&1 || {
fail_or_skip "Failed to clone managed goose checkout from ${clone_url} into ${goose_repo}."
}
}
ensure_checkout_exists
if [[ "${allow_dirty}" != "1" ]]; then
if [[ -n "$(git -C "${goose_repo}" status --porcelain)" ]]; then
fail_or_skip "Managed goose checkout at ${goose_repo} is dirty. Use a dedicated checkout or set GOOSE_DEV_ALLOW_DIRTY=1."
fi
fi
if resolve_branch; then
branch="${RESOLVED_BRANCH}"
remote_head="${RESOLVED_REMOTE_HEAD}"
else
resolve_branch_status=$?
case "${resolve_branch_status}" in
1)
exit 1
;;
2)
exit 0
;;
*)
echo "Unexpected resolve_branch status: ${resolve_branch_status}" >&2
exit 1
;;
esac
fi
if [[ "${action}" == "check" ]]; then
if [[ ! -f "${stamp_file}" ]]; then
fail_or_skip "Managed goose checkout is configured, but no local goose build stamp was found. Rerun just setup."
fi
# shellcheck disable=SC1090
source "${stamp_file}"
if [[ "${STAMP_REPO:-}" != "${goose_repo}" ]]; then
fail_or_skip "Managed goose checkout changed since the last local goose build. Rerun just setup."
fi
if [[ "${STAMP_BRANCH:-}" != "${branch}" ]]; then
fail_or_skip "Managed goose branch is now ${branch}, but the local goose build was prepared for ${STAMP_BRANCH:-unknown}. Rerun just setup."
fi
if [[ ! -x "${STAMP_BIN:-}" ]]; then
fail_or_skip "Local goose binary was not found at ${STAMP_BIN:-unknown}. Rerun just setup."
fi
local_head="$(git -C "${goose_repo}" rev-parse HEAD)"
if [[ "${STAMP_COMMIT:-}" != "${local_head}" ]]; then
fail_or_skip "Managed goose checkout changed after the last local build. Rerun just setup."
fi
if [[ "${STAMP_COMMIT:-}" != "${remote_head}" ]]; then
fail_or_skip "Managed goose checkout is behind ${remote}/${branch}. Rerun just setup."
fi
if [[ "${print_bin}" == "1" ]]; then
printf '%s\n' "${STAMP_BIN}"
fi
exit 0
fi
git -C "${goose_repo}" fetch "${remote}" "${branch}" >/dev/null 2>&1
remote_ref="refs/remotes/${remote}/${branch}"
if ! git -C "${goose_repo}" show-ref --verify --quiet "${remote_ref}"; then
fail_or_skip "Fetched ${remote}/${branch}, but ${remote_ref} is not available in ${goose_repo}."
fi
if git -C "${goose_repo}" show-ref --verify --quiet "refs/heads/${branch}"; then
git -C "${goose_repo}" checkout "${branch}" >/dev/null 2>&1
else
git -C "${goose_repo}" checkout -b "${branch}" --track "${remote}/${branch}" >/dev/null 2>&1
fi
# Reset to the remote head. This is a managed build-only checkout, so we
# always want to match the remote exactly. A plain `pull --ff-only` would
# fail when the remote branch has been force-pushed (rebased/amended).
git -C "${goose_repo}" reset --hard "${remote}/${branch}" >/dev/null 2>&1
log "Building goose from ${goose_repo} on ${branch}."
(
cd "${goose_repo}"
cargo build -p goose-cli --bin goose
)
if [[ -n "$(git -C "${goose_repo}" status --porcelain -- Cargo.lock)" ]]; then
# Cargo may refresh the lockfile while compiling a freshly synced checkout.
# This managed repo is only a build source for goose2, so restore the tracked
# lockfile to keep the checkout clean for later preflight checks.
git -C "${goose_repo}" checkout -- Cargo.lock
fi
if [[ ! -x "${bin_path}" ]]; then
echo "Expected goose binary at ${bin_path}, but it was not built successfully." >&2
exit 1
fi
write_stamp "${branch}" "$(git -C "${goose_repo}" rev-parse HEAD)"
log "Local goose binary ready at ${bin_path}."
if [[ "${print_bin}" == "1" ]]; then
printf '%s\n' "${bin_path}"
fi
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env swift
import AppKit
import Foundation
// 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]
// Load the icns file
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)
// Create a new image with the badge
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 && fontSize > 8
// 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()
// Convert to icns format
// 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 iconsetPath = tempDir.appendingPathComponent("goose-dev.iconset")
// Remove existing iconset if present
try? FileManager.default.removeItem(at: iconsetPath)
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()
// Cleanup
try? FileManager.default.removeItem(at: iconsetPath)
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)")