Compare commits

..
Author SHA1 Message Date
kua-agent fa3e7b0a4a feature: user-selectable capture hotkey with recorder in Settings 2026-09-01 22:33:13 +04:00
8 changed files with 258 additions and 342 deletions
-2
View File
@@ -18,8 +18,6 @@
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
Binary file not shown.
+33 -7
View File
@@ -1,5 +1,4 @@
import AppKit
import Carbon.HIToolbox
import Foundation
import Observation
import SwiftUI
@@ -37,6 +36,9 @@ public final class AppModel {
public private(set) var outboxURL: URL
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
public private(set) var watchFolderURL: URL
/// Currently bound capture combo (the last one Carbon accepted, or the preferred load).
private(set) var captureHotkey: HotkeyPreference
var hotkeyDisplayString: String { captureHotkey.displayString }
let paths: AppSupportPaths
let spool: SpoolStore
@@ -80,6 +82,7 @@ public final class AppModel {
self.watchFolderURL = folders.watch
self.outboxDisplayName = folders.outbox.lastPathComponent
self.watchFolderDisplayName = folders.watch.lastPathComponent
self.captureHotkey = HotkeyPreference.load()
}
// MARK: Seam mutators the only way a WP-4b/4c extension changes state.
@@ -146,16 +149,39 @@ public final class AppModel {
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.")
}
let registered = hotkeys.register(
let pref = HotkeyPreference.load()
captureHotkey = pref
if !bindCaptureHotkey(pref) {
setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.")
}
}
/// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new
/// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working.
func reRegisterHotkey() {
let previous = captureHotkey
let next = HotkeyPreference.load()
hotkeys.unregister(id: "capture")
if bindCaptureHotkey(next) {
captureHotkey = next
return
}
setStatus("That combination is taken — pick another.")
previous.save()
if bindCaptureHotkey(previous) {
captureHotkey = previous
}
}
@discardableResult
private func bindCaptureHotkey(_ pref: HotkeyPreference) -> Bool {
hotkeys.register(
id: "capture",
keyCode: UInt32(kVK_ANSI_2),
modifiers: UInt32(optionKey | shiftKey)
keyCode: pref.keyCode,
modifiers: pref.modifiers
) { [weak self] in
Task { await self?.captureNow() }
}
if !registered {
setStatus("⌥⇧2 is already used by another app — capture only works from the menu.")
}
}
public func captureNow() async {
+151
View File
@@ -0,0 +1,151 @@
import AppKit
import Carbon.HIToolbox
import Foundation
/// User-chosen capture hotkey. `modifiers` are Carbon bits (`cmdKey`, `optionKey`,
/// `shiftKey`, `controlKey`), matching `HotkeyCenter.register`.
struct HotkeyPreference: Codable, Equatable, Sendable {
var keyCode: UInt32
var modifiers: UInt32
static let defaultsKey = "ai.flowmaster.shotdeck.hotkey"
/// 2 kVK_ANSI_2 (19) with optionKey|shiftKey.
static let `default` = HotkeyPreference(
keyCode: 19,
modifiers: UInt32(optionKey) | UInt32(shiftKey)
)
static func load(defaults: UserDefaults = .standard) -> HotkeyPreference {
guard let data = defaults.data(forKey: defaultsKey),
let decoded = try? JSONDecoder().decode(HotkeyPreference.self, from: data),
decoded.modifiers != 0
else { return .default }
return decoded
}
func save(defaults: UserDefaults = .standard) {
guard let data = try? JSONEncoder().encode(self) else { return }
defaults.set(data, forKey: Self.defaultsKey)
}
/// in that order, then a name for common keycodes.
var displayString: String {
var s = ""
if modifiers & UInt32(cmdKey) != 0 { s += "" }
if modifiers & UInt32(optionKey) != 0 { s += "" }
if modifiers & UInt32(shiftKey) != 0 { s += "" }
if modifiers & UInt32(controlKey) != 0 { s += "" }
s += Self.keyName(for: keyCode)
return s
}
/// `nil` when the event is modifier-only or has no flags.
static func fromKeyEvent(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) -> HotkeyPreference? {
switch Int(keyCode) {
case kVK_Shift, kVK_RightShift,
kVK_Command, kVK_RightCommand,
kVK_Option, kVK_RightOption,
kVK_Control, kVK_RightControl,
kVK_CapsLock, kVK_Function:
return nil
default:
break
}
var carbon: UInt32 = 0
if modifierFlags.contains(.command) { carbon |= UInt32(cmdKey) }
if modifierFlags.contains(.option) { carbon |= UInt32(optionKey) }
if modifierFlags.contains(.shift) { carbon |= UInt32(shiftKey) }
if modifierFlags.contains(.control) { carbon |= UInt32(controlKey) }
guard carbon != 0 else { return nil }
return HotkeyPreference(keyCode: UInt32(keyCode), modifiers: carbon)
}
private static func keyName(for keyCode: UInt32) -> String {
switch Int(keyCode) {
case kVK_ANSI_A: return "A"
case kVK_ANSI_B: return "B"
case kVK_ANSI_C: return "C"
case kVK_ANSI_D: return "D"
case kVK_ANSI_E: return "E"
case kVK_ANSI_F: return "F"
case kVK_ANSI_G: return "G"
case kVK_ANSI_H: return "H"
case kVK_ANSI_I: return "I"
case kVK_ANSI_J: return "J"
case kVK_ANSI_K: return "K"
case kVK_ANSI_L: return "L"
case kVK_ANSI_M: return "M"
case kVK_ANSI_N: return "N"
case kVK_ANSI_O: return "O"
case kVK_ANSI_P: return "P"
case kVK_ANSI_Q: return "Q"
case kVK_ANSI_R: return "R"
case kVK_ANSI_S: return "S"
case kVK_ANSI_T: return "T"
case kVK_ANSI_U: return "U"
case kVK_ANSI_V: return "V"
case kVK_ANSI_W: return "W"
case kVK_ANSI_X: return "X"
case kVK_ANSI_Y: return "Y"
case kVK_ANSI_Z: return "Z"
case kVK_ANSI_0: return "0"
case kVK_ANSI_1: return "1"
case kVK_ANSI_2: return "2"
case kVK_ANSI_3: return "3"
case kVK_ANSI_4: return "4"
case kVK_ANSI_5: return "5"
case kVK_ANSI_6: return "6"
case kVK_ANSI_7: return "7"
case kVK_ANSI_8: return "8"
case kVK_ANSI_9: return "9"
case kVK_ANSI_Equal: return "="
case kVK_ANSI_Minus: return "-"
case kVK_ANSI_RightBracket: return "]"
case kVK_ANSI_LeftBracket: return "["
case kVK_ANSI_Quote: return "'"
case kVK_ANSI_Semicolon: return ";"
case kVK_ANSI_Backslash: return "\\"
case kVK_ANSI_Comma: return ","
case kVK_ANSI_Slash: return "/"
case kVK_ANSI_Period: return "."
case kVK_ANSI_Grave: return "`"
case kVK_Space: return "Space"
case kVK_Return: return "Return"
case kVK_Tab: return "Tab"
case kVK_Delete: return "Delete"
case kVK_ForwardDelete: return "Fwd Delete"
case kVK_Escape: return "Esc"
case kVK_Home: return "Home"
case kVK_End: return "End"
case kVK_PageUp: return "Page Up"
case kVK_PageDown: return "Page Down"
case kVK_Help: return "Help"
case kVK_LeftArrow: return ""
case kVK_RightArrow: return ""
case kVK_DownArrow: return ""
case kVK_UpArrow: return ""
case kVK_F1: return "F1"
case kVK_F2: return "F2"
case kVK_F3: return "F3"
case kVK_F4: return "F4"
case kVK_F5: return "F5"
case kVK_F6: return "F6"
case kVK_F7: return "F7"
case kVK_F8: return "F8"
case kVK_F9: return "F9"
case kVK_F10: return "F10"
case kVK_F11: return "F11"
case kVK_F12: return "F12"
case kVK_F13: return "F13"
case kVK_F14: return "F14"
case kVK_F15: return "F15"
case kVK_F16: return "F16"
case kVK_F17: return "F17"
case kVK_F18: return "F18"
case kVK_F19: return "F19"
case kVK_F20: return "F20"
default: return "Key \(keyCode)"
}
}
}
+2 -2
View File
@@ -49,7 +49,7 @@ struct MenuBarView: View {
private var defaultStatusText: String {
guard let region = model.region else {
return "No region yet — press ⌥⇧2 to pick one."
return "No region yet — press \(model.hotkeyDisplayString) to pick one."
}
let w = Int(region.rect.width)
let h = Int(region.rect.height)
@@ -89,7 +89,7 @@ struct MenuBarView: View {
Button {
Task { await model.captureNow() }
} label: {
actionLabel("Capture now", trailing: "⌥⇧2")
actionLabel("Capture now", trailing: model.hotkeyDisplayString)
}
.disabled(model.isCapturing)
+72 -6
View File
@@ -4,6 +4,8 @@ import ShotdeckCore
struct SettingsView: View {
@Environment(AppModel.self) private var model
@State private var isRecordingHotkey = false
@State private var recorder = HotkeyRecorderBox()
private let labelWidth: CGFloat = 104
@@ -16,13 +18,19 @@ struct SettingsView: View {
.gridCellColumns(2)
}
GridRow(alignment: .firstTextBaseline) {
GridRow(alignment: .center) {
fieldLabel("Capture")
Text("⌥⇧2 — fixed in this version")
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(minHeight: 22, alignment: .leading)
HStack(spacing: 8) {
Button {
armHotkeyRecorder()
} label: {
Text(isRecordingHotkey ? "Press keys…" : model.hotkeyDisplayString)
.foregroundStyle(isRecordingHotkey ? .secondary : .primary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.frame(minHeight: 22)
}
GridRow {
@@ -57,6 +65,35 @@ struct SettingsView: View {
.padding(16)
.frame(minWidth: 320, idealWidth: 360, maxWidth: 360, alignment: .leading)
.controlSize(.small)
.onDisappear { disarmHotkeyRecorder() }
}
private func armHotkeyRecorder() {
guard !isRecordingHotkey else { return }
isRecordingHotkey = true
recorder.onKey = { keyCode, flags in
handleRecorderKey(keyCode: keyCode, flags: flags)
}
recorder.arm()
}
private func handleRecorderKey(keyCode: UInt16, flags: NSEvent.ModifierFlags) {
if keyCode == 53 { // kVK_Escape
disarmHotkeyRecorder()
return
}
guard let pref = HotkeyPreference.fromKeyEvent(keyCode: keyCode, modifierFlags: flags) else {
return
}
pref.save()
model.reRegisterHotkey()
disarmHotkeyRecorder()
}
private func disarmHotkeyRecorder() {
recorder.disarm()
recorder.onKey = nil
isRecordingHotkey = false
}
private func fieldLabel(_ title: String) -> some View {
@@ -80,6 +117,35 @@ struct SettingsView: View {
}
}
/// Local keyDown monitor for the Settings capture-hotkey recorder. Callbacks hop onto the
/// main actor the same way `RegionPickerController` does local monitors fire on the
/// main run loop during `NSApp.sendEvent`.
@MainActor
private final class HotkeyRecorderBox {
var onKey: ((UInt16, NSEvent.ModifierFlags) -> Void)?
private var monitor: Any?
func arm() {
disarm()
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
let keyCode = event.keyCode
let rawFlags = event.modifierFlags.rawValue
MainActor.assumeIsolated {
self.onKey?(keyCode, NSEvent.ModifierFlags(rawValue: rawFlags))
}
return nil
}
}
func disarm() {
if let monitor {
NSEvent.removeMonitor(monitor)
}
monitor = nil
}
}
extension AppModel: SettingsWindowPresenting {
private static var settingsWindowController: NSWindowController?
-1
View File
@@ -43,7 +43,6 @@ mkdir -p "${APP_BUNDLE}/Contents/Resources"
cp "${BIN_PATH}" "${APP_BUNDLE}/Contents/MacOS/Shotdeck"
chmod +x "${APP_BUNDLE}/Contents/MacOS/Shotdeck"
cp "${ROOT}/Info.plist" "${APP_BUNDLE}/Contents/Info.plist"
cp "${ROOT}/Resources/AppIcon.icns" "${APP_BUNDLE}/Contents/Resources/AppIcon.icns"
if [[ "${SKIP_SIGN}" -eq 1 ]]; then
echo
-324
View File
@@ -1,324 +0,0 @@
#!/usr/bin/env swift
import Foundation
import CoreGraphics
import ImageIO
// Programmatic Redline app icon.
// Draws a 1024×1024 master, writes AppIcon.iconset (161024 incl. @2x),
// and compiles Resources/AppIcon.icns via iconutil.
//
// Usage:
// swift scripts/make-icon.swift
// swift scripts/make-icon.swift --preview /path/to/icon-512.png
private let masterSize = 1024
private enum Palette {
static let charcoalTop = CGColor(srgbRed: 44.0 / 255.0, green: 44.0 / 255.0, blue: 46.0 / 255.0, alpha: 1)
static let charcoalBottom = CGColor(srgbRed: 28.0 / 255.0, green: 28.0 / 255.0, blue: 30.0 / 255.0, alpha: 1) // #1C1C1E
static let white = CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
static let redline = CGColor(srgbRed: 229.0 / 255.0, green: 72.0 / 255.0, blue: 63.0 / 255.0, alpha: 1) // #E5483F
}
private struct IconsetEntry {
let filename: String
let pixels: Int
}
private let iconsetEntries: [IconsetEntry] = [
IconsetEntry(filename: "icon_16x16.png", pixels: 16),
IconsetEntry(filename: "icon_16x16@2x.png", pixels: 32),
IconsetEntry(filename: "icon_32x32.png", pixels: 32),
IconsetEntry(filename: "icon_32x32@2x.png", pixels: 64),
IconsetEntry(filename: "icon_128x128.png", pixels: 128),
IconsetEntry(filename: "icon_128x128@2x.png", pixels: 256),
IconsetEntry(filename: "icon_256x256.png", pixels: 256),
IconsetEntry(filename: "icon_256x256@2x.png", pixels: 512),
IconsetEntry(filename: "icon_512x512.png", pixels: 512),
IconsetEntry(filename: "icon_512x512@2x.png", pixels: 1024),
]
// MARK: - Geometry
/// Apple-style continuous-corner rounded square (squircle-like).
/// Superellipse |x/a|^n + |y/b|^n = 1 with n5, inset 1px so antialiased
/// edge pixels are not clipped by the bitmap.
private func continuousRoundedSquare(size: CGFloat, exponent n: CGFloat = 5.0, segments: Int = 256) -> CGPath {
let inset: CGFloat = 1
let a = (size - inset * 2) / 2
let cx = size / 2
let cy = size / 2
let twoOverN = 2 / n
let path = CGMutablePath()
for i in 0...segments {
let theta = CGFloat(i) / CGFloat(segments) * 2 * .pi
let ct = cos(theta)
let st = sin(theta)
let x = cx + (ct < 0 ? -1 : 1) * pow(abs(ct), twoOverN) * a
let y = cy + (st < 0 ? -1 : 1) * pow(abs(st), twoOverN) * a
if i == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
path.closeSubpath()
return path
}
// MARK: - Drawing
private func drawMasterIcon(size: Int) -> CGImage {
let s = CGFloat(size)
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue
guard let ctx = CGContext(
data: nil,
width: size,
height: size,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: bitmapInfo
) else {
fputs("error: failed to create \(size)×\(size) bitmap context\n", stderr)
exit(1)
}
ctx.setShouldAntialias(true)
ctx.setAllowsAntialiasing(true)
ctx.interpolationQuality = .high
// Flip to top-left origin so "top-to-bottom gradient" is literal.
ctx.translateBy(x: 0, y: s)
ctx.scaleBy(x: 1, y: -1)
ctx.clear(CGRect(x: 0, y: 0, width: s, height: s))
let squircle = continuousRoundedSquare(size: s)
ctx.saveGState()
ctx.addPath(squircle)
ctx.clip()
let gradient = CGGradient(
colorsSpace: colorSpace,
colors: [Palette.charcoalTop, Palette.charcoalBottom] as CFArray,
locations: [0, 1]
)!
ctx.drawLinearGradient(
gradient,
start: CGPoint(x: s / 2, y: 0),
end: CGPoint(x: s / 2, y: s),
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
)
ctx.restoreGState()
// Viewfinder corner brackets: thick L-strokes, rounded caps/joins, inset ~18%.
let inset = s * 0.18
let bracketWidth = s * 0.095
let arm = s * 0.155
let centerline = inset + bracketWidth / 2
ctx.saveGState()
ctx.addPath(squircle)
ctx.clip()
ctx.setStrokeColor(Palette.white)
ctx.setLineWidth(bracketWidth)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
func strokeBracket(cornerX: CGFloat, cornerY: CGFloat, dirX: CGFloat, dirY: CGFloat) {
let path = CGMutablePath()
path.move(to: CGPoint(x: cornerX + dirX * arm, y: cornerY))
path.addLine(to: CGPoint(x: cornerX, y: cornerY))
path.addLine(to: CGPoint(x: cornerX, y: cornerY + dirY * arm))
ctx.addPath(path)
ctx.strokePath()
}
// Top-left, top-right, bottom-left, bottom-right.
strokeBracket(cornerX: centerline, cornerY: centerline, dirX: 1, dirY: 1)
strokeBracket(cornerX: s - centerline, cornerY: centerline, dirX: -1, dirY: 1)
strokeBracket(cornerX: centerline, cornerY: s - centerline, dirX: 1, dirY: -1)
strokeBracket(cornerX: s - centerline, cornerY: s - centerline, dirX: -1, dirY: -1)
// Bold redline slash over the frame, lower-left bracket area upper-right.
let slashWidth = s * 0.078
ctx.setStrokeColor(Palette.redline)
ctx.setLineWidth(slashWidth)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
let slashInset = centerline + arm * 0.12
let slash = CGMutablePath()
slash.move(to: CGPoint(x: slashInset, y: s - slashInset))
slash.addLine(to: CGPoint(x: s - slashInset, y: slashInset))
ctx.addPath(slash)
ctx.strokePath()
ctx.restoreGState()
guard let image = ctx.makeImage() else {
fputs("error: failed to materialize master CGImage\n", stderr)
exit(1)
}
return image
}
private func scaledImage(_ image: CGImage, pixels: Int) -> CGImage {
if image.width == pixels && image.height == pixels {
return image
}
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue
guard let ctx = CGContext(
data: nil,
width: pixels,
height: pixels,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: bitmapInfo
) else {
fputs("error: failed to create \(pixels)×\(pixels) scale context\n", stderr)
exit(1)
}
ctx.interpolationQuality = .high
ctx.setShouldAntialias(true)
ctx.draw(image, in: CGRect(x: 0, y: 0, width: pixels, height: pixels))
guard let out = ctx.makeImage() else {
fputs("error: failed to scale image to \(pixels)px\n", stderr)
exit(1)
}
return out
}
private func writePNG(_ image: CGImage, to url: URL) {
let dir = url.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
if FileManager.default.fileExists(atPath: url.path) {
try? FileManager.default.removeItem(at: url)
}
guard let dest = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
fputs("error: cannot create PNG destination at \(url.path)\n", stderr)
exit(1)
}
CGImageDestinationAddImage(dest, image, nil)
if !CGImageDestinationFinalize(dest) {
fputs("error: failed to write PNG \(url.path)\n", stderr)
exit(1)
}
}
// MARK: - Paths / CLI
private func repoRoot() -> URL {
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)
let arg0 = URL(fileURLWithPath: CommandLine.arguments[0])
let scriptURL: URL
if arg0.path.hasPrefix("/") {
scriptURL = arg0.standardizedFileURL
} else {
scriptURL = cwd.appendingPathComponent(arg0.path).standardizedFileURL
}
let fromScript = scriptURL.deletingLastPathComponent().deletingLastPathComponent()
if FileManager.default.fileExists(atPath: fromScript.appendingPathComponent("Package.swift").path) {
return fromScript
}
if FileManager.default.fileExists(atPath: cwd.appendingPathComponent("Package.swift").path) {
return cwd
}
fputs("error: could not find repo root (Package.swift)\n", stderr)
exit(1)
}
private func parsePreviewPath() -> String? {
let args = CommandLine.arguments
var i = 1
var preview: String?
while i < args.count {
let arg = args[i]
if arg == "--preview" {
i += 1
guard i < args.count else {
fputs("error: --preview requires a path\n", stderr)
exit(1)
}
preview = args[i]
} else if arg.hasPrefix("--preview=") {
preview = String(arg.dropFirst("--preview=".count))
} else if arg == "--help" || arg == "-h" {
fputs("Usage: swift scripts/make-icon.swift [--preview PATH]\n", stderr)
exit(0)
} else {
fputs("error: unknown argument \(arg)\n", stderr)
fputs("Usage: swift scripts/make-icon.swift [--preview PATH]\n", stderr)
exit(1)
}
i += 1
}
return preview
}
private func runIconutil(iconset: URL, icns: URL) {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
proc.arguments = ["-c", "icns", iconset.path, "-o", icns.path]
proc.standardOutput = FileHandle.standardOutput
proc.standardError = FileHandle.standardError
do {
try proc.run()
proc.waitUntilExit()
} catch {
fputs("error: failed to launch iconutil: \(error)\n", stderr)
exit(1)
}
if proc.terminationStatus != 0 {
fputs("error: iconutil exited \(proc.terminationStatus)\n", stderr)
exit(1)
}
}
// MARK: - Main
let root = repoRoot()
let resources = root.appendingPathComponent("Resources", isDirectory: true)
let icnsURL = resources.appendingPathComponent("AppIcon.icns")
let previewPath = parsePreviewPath()
let fm = FileManager.default
print("==> Drawing \(masterSize)×\(masterSize) master")
let master = drawMasterIcon(size: masterSize)
let iconset = fm.temporaryDirectory.appendingPathComponent("Redline-AppIcon-\(UUID().uuidString).iconset", isDirectory: true)
do {
try fm.createDirectory(at: iconset, withIntermediateDirectories: true)
try fm.createDirectory(at: resources, withIntermediateDirectories: true)
print("==> Writing AppIcon.iconset")
for entry in iconsetEntries {
let img = scaledImage(master, pixels: entry.pixels)
writePNG(img, to: iconset.appendingPathComponent(entry.filename))
}
if fm.fileExists(atPath: icnsURL.path) {
try fm.removeItem(at: icnsURL)
}
print("==> Compiling \(icnsURL.path)")
runIconutil(iconset: iconset, icns: icnsURL)
try? fm.removeItem(at: iconset)
} catch {
try? fm.removeItem(at: iconset)
fputs("error: \(error)\n", stderr)
exit(1)
}
if let previewPath {
let previewURL = URL(fileURLWithPath: previewPath)
print("==> Writing 512px preview \(previewURL.path)")
writePNG(scaledImage(master, pixels: 512), to: previewURL)
}
print("App icon: \(icnsURL.path)")