diff --git a/MCT/API/LoginItemManager.swift b/MCT/API/LoginItemManager.swift
new file mode 100644
index 0000000..00a33ca
--- /dev/null
+++ b/MCT/API/LoginItemManager.swift
@@ -0,0 +1,30 @@
+import Foundation
+import ServiceManagement
+
+/// Wraps SMAppService.mainApp so MCT can register itself as a login item.
+/// Requires macOS 13+ (project targets 14.0).
+enum LoginItemManager {
+ static var isEnabled: Bool {
+ SMAppService.mainApp.status == .enabled
+ }
+
+ @discardableResult
+ static func setEnabled(_ enabled: Bool) -> Bool {
+ let service = SMAppService.mainApp
+ do {
+ if enabled {
+ if service.status != .enabled {
+ try service.register()
+ }
+ } else {
+ if service.status == .enabled {
+ try service.unregister()
+ }
+ }
+ return true
+ } catch {
+ mctLog("[MCT] login item %@ failed: %@", enabled ? "register" : "unregister", String(describing: error))
+ return false
+ }
+ }
+}
diff --git a/MCT/App/AppDelegate.swift b/MCT/App/AppDelegate.swift
index a906200..1bd38e1 100644
--- a/MCT/App/AppDelegate.swift
+++ b/MCT/App/AppDelegate.swift
@@ -26,6 +26,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
mctLog("[MCT] app launched")
checkPermissions()
+ syncLoginItem()
setupHotKey()
Task { await ThumbnailCapture.warmUp() }
setupGestureMonitor()
@@ -59,6 +60,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Private
+ private func syncLoginItem() {
+ // Only register from /Applications — SMAppService records the bundle
+ // path it was registered from, so dev builds in ~/misc would otherwise
+ // pin the login item to the wrong location.
+ let bundlePath = Bundle.main.bundlePath
+ guard bundlePath.hasPrefix("/Applications/") else {
+ mctLog("[MCT] not registering login item — not running from /Applications (%@)", bundlePath)
+ return
+ }
+ if Preferences.shared.launchAtLogin {
+ LoginItemManager.setEnabled(true)
+ }
+ }
+
private func checkPermissions() {
if !AccessibilityBridge.hasPermission {
let hasPromptedBefore = UserDefaults.standard.bool(forKey: "hasPromptedAccessibility")
diff --git a/MCT/Settings/Preferences.swift b/MCT/Settings/Preferences.swift
index 18a81bc..6439031 100644
--- a/MCT/Settings/Preferences.swift
+++ b/MCT/Settings/Preferences.swift
@@ -17,6 +17,13 @@ final class Preferences {
didSet { defaults.set(hotKeyModifiers, forKey: "hotKeyModifiers") }
}
+ var launchAtLogin: Bool {
+ didSet {
+ defaults.set(launchAtLogin, forKey: "launchAtLogin")
+ LoginItemManager.setEnabled(launchAtLogin)
+ }
+ }
+
/// Human-readable description of the current hotkey.
var hotKeyDescription: String {
var parts: [String] = []
@@ -39,8 +46,10 @@ final class Preferences {
private init() {
let storedKeyCode = defaults.object(forKey: "hotKeyKeyCode") as? UInt32
let storedModifiers = defaults.object(forKey: "hotKeyModifiers") as? UInt32
+ let storedLaunchAtLogin = defaults.object(forKey: "launchAtLogin") as? Bool
self.hotKeyKeyCode = storedKeyCode ?? UInt32(kVK_UpArrow)
self.hotKeyModifiers = storedModifiers ?? UInt32(controlKey)
+ self.launchAtLogin = storedLaunchAtLogin ?? true
}
}
diff --git a/MCT/Settings/SettingsView.swift b/MCT/Settings/SettingsView.swift
index 5b84632..4f4e5c3 100644
--- a/MCT/Settings/SettingsView.swift
+++ b/MCT/Settings/SettingsView.swift
@@ -24,6 +24,16 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}
+ Section("Startup") {
+ Toggle("Launch at login", isOn: Binding(
+ get: { prefs.launchAtLogin },
+ set: { prefs.launchAtLogin = $0 }
+ ))
+ Text("MCT must be in /Applications for this to work.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
Section("Permissions") {
LabeledContent("Accessibility") {
if AccessibilityBridge.hasPermission {
diff --git a/build.sh b/build.sh
index 087590e..0f3f876 100755
--- a/build.sh
+++ b/build.sh
@@ -11,16 +11,19 @@ fi
CERT_NAME="${CERT_NAME:-}"
-APP_DIR="$(dirname "$0")/build/MCT.app"
+PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
+APP_DIR="$PROJECT_DIR/build/MCT.app"
+INSTALL_DIR="/Applications/MCT.app"
SDK=$(xcrun --show-sdk-path)
echo "Building MCT..."
-# Kill running instance if any
+# Kill running instance if any (matches both build/MCT.app and /Applications/MCT.app)
pkill -x MCT 2>/dev/null || true
sleep 0.3
-# Create bundle structure
+# Clean previous bundle so stale resources don't linger
+rm -rf "$APP_DIR"
mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
# Compile
@@ -42,6 +45,7 @@ swiftc \
MCT/API/AccessibilityBridge.swift \
MCT/API/HotKeyManager.swift \
MCT/API/GestureMonitor.swift \
+ MCT/API/LoginItemManager.swift \
MCT/Layout/LayoutGeometry.swift \
MCT/Layout/LayoutEngine.swift \
MCT/UI/SpringAnimator.swift \
@@ -54,6 +58,32 @@ swiftc \
MCT/App/AppDelegate.swift \
MCT/App/MCTApp.swift
+# App icon — generate AppIcon.icns once and cache it
+ICON_SRC="$PROJECT_DIR/tools/generate_icon.swift"
+ICON_CACHE_DIR="$PROJECT_DIR/build/icon"
+ICONSET_DIR="$ICON_CACHE_DIR/AppIcon.iconset"
+ICON_PNG="$ICON_CACHE_DIR/icon_1024.png"
+ICON_ICNS="$ICON_CACHE_DIR/AppIcon.icns"
+
+if [ ! -f "$ICON_ICNS" ] || [ "$ICON_SRC" -nt "$ICON_ICNS" ]; then
+ echo "Generating app icon..."
+ mkdir -p "$ICONSET_DIR"
+ swift "$ICON_SRC" "$ICON_PNG"
+ # Standard iconutil layout
+ sips -z 16 16 "$ICON_PNG" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null
+ sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null
+ sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null
+ sips -z 64 64 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null
+ sips -z 128 128 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null
+ sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null
+ sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null
+ sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null
+ sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null
+ cp "$ICON_PNG" "$ICONSET_DIR/icon_512x512@2x.png"
+ iconutil --convert icns "$ICONSET_DIR" --output "$ICON_ICNS"
+fi
+cp "$ICON_ICNS" "$APP_DIR/Contents/Resources/AppIcon.icns"
+
# Info.plist
cat > "$APP_DIR/Contents/Info.plist" << 'PLIST'
@@ -64,6 +94,10 @@ cat > "$APP_DIR/Contents/Info.plist" << 'PLIST'
en
CFBundleExecutable
MCT
+ CFBundleIconFile
+ AppIcon
+ CFBundleIconName
+ AppIcon
CFBundleIdentifier
com.mct.app
CFBundleInfoDictionaryVersion
@@ -102,4 +136,21 @@ else
fi
echo "Built: $APP_DIR"
-open "$APP_DIR"
+
+# Install into /Applications so launch-at-login resolves to a stable path,
+# permission grants survive rebuilds, and the user can launch it like a normal app.
+echo "Installing to $INSTALL_DIR..."
+rm -rf "$INSTALL_DIR"
+cp -R "$APP_DIR" "$INSTALL_DIR"
+# Re-sign in place — codesign can be path-sensitive on some setups.
+if [ -n "$CERT_NAME" ]; then
+ codesign --force --sign "$CERT_NAME" "$INSTALL_DIR"
+else
+ codesign --force --sign - "$INSTALL_DIR"
+fi
+# Refresh Launch Services so Finder/launchd see the new bundle.
+/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
+ -f "$INSTALL_DIR" >/dev/null 2>&1 || true
+
+echo "Installed: $INSTALL_DIR"
+open "$INSTALL_DIR"
diff --git a/tools/generate_icon.swift b/tools/generate_icon.swift
new file mode 100644
index 0000000..a2d0b94
--- /dev/null
+++ b/tools/generate_icon.swift
@@ -0,0 +1,200 @@
+import AppKit
+import CoreGraphics
+import Foundation
+
+// Renders the MCT app icon at 1024x1024 as a PNG to the path given as argv[1].
+// The build script then uses sips + iconutil to produce all .icns sizes.
+
+guard CommandLine.arguments.count >= 2 else {
+ FileHandle.standardError.write("usage: generate_icon \n".data(using: .utf8)!)
+ exit(1)
+}
+
+let outPath = CommandLine.arguments[1]
+let size = 1024
+
+guard let ctx = CGContext(
+ data: nil,
+ width: size,
+ height: size,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+) else {
+ fputs("CGContext failed\n", stderr)
+ exit(1)
+}
+
+let s = CGFloat(size)
+let rect = CGRect(x: 0, y: 0, width: s, height: s)
+
+// Background squircle. Apple's "icon shape" corner radius is roughly 22.37% of side.
+let inset: CGFloat = s * 0.06
+let bgRect = rect.insetBy(dx: inset, dy: inset)
+let cornerRadius: CGFloat = bgRect.width * 0.2237
+let bgPath = CGPath(roundedRect: bgRect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
+
+ctx.saveGState()
+ctx.addPath(bgPath)
+ctx.clip()
+
+let bgColors = [
+ CGColor(red: 0.18, green: 0.22, blue: 0.40, alpha: 1.0),
+ CGColor(red: 0.08, green: 0.10, blue: 0.22, alpha: 1.0),
+]
+let bgGradient = CGGradient(
+ colorsSpace: CGColorSpaceCreateDeviceRGB(),
+ colors: bgColors as CFArray,
+ locations: [0.0, 1.0]
+)!
+ctx.drawLinearGradient(
+ bgGradient,
+ start: CGPoint(x: bgRect.minX, y: bgRect.maxY),
+ end: CGPoint(x: bgRect.maxX, y: bgRect.minY),
+ options: []
+)
+
+// Subtle inner gloss across top
+let glossColors = [
+ CGColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.18),
+ CGColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.0),
+]
+let glossGradient = CGGradient(
+ colorsSpace: CGColorSpaceCreateDeviceRGB(),
+ colors: glossColors as CFArray,
+ locations: [0.0, 1.0]
+)!
+ctx.drawLinearGradient(
+ glossGradient,
+ start: CGPoint(x: bgRect.midX, y: bgRect.maxY),
+ end: CGPoint(x: bgRect.midX, y: bgRect.midY),
+ options: []
+)
+ctx.restoreGState()
+
+// 2x2 grid of window tiles
+struct Tile {
+ let color: CGColor
+ let accent: CGColor
+}
+
+let tiles: [Tile] = [
+ Tile(
+ color: CGColor(red: 1.0, green: 0.42, blue: 0.42, alpha: 1.0),
+ accent: CGColor(red: 1.0, green: 0.65, blue: 0.65, alpha: 1.0)
+ ),
+ Tile(
+ color: CGColor(red: 0.36, green: 0.78, blue: 1.0, alpha: 1.0),
+ accent: CGColor(red: 0.60, green: 0.88, blue: 1.0, alpha: 1.0)
+ ),
+ Tile(
+ color: CGColor(red: 1.0, green: 0.78, blue: 0.32, alpha: 1.0),
+ accent: CGColor(red: 1.0, green: 0.88, blue: 0.55, alpha: 1.0)
+ ),
+ Tile(
+ color: CGColor(red: 0.46, green: 0.86, blue: 0.50, alpha: 1.0),
+ accent: CGColor(red: 0.70, green: 0.95, blue: 0.72, alpha: 1.0)
+ ),
+]
+
+let gridPadding: CGFloat = s * 0.16
+let gridGap: CGFloat = s * 0.04
+let gridRect = bgRect.insetBy(dx: gridPadding, dy: gridPadding)
+let tileW = (gridRect.width - gridGap) / 2.0
+let tileH = (gridRect.height - gridGap) / 2.0
+let tileCorner = tileW * 0.14
+
+func tileRect(col: Int, row: Int) -> CGRect {
+ // row 0 = top, row 1 = bottom — but CGContext origin is bottom-left.
+ let x = gridRect.minX + CGFloat(col) * (tileW + gridGap)
+ let y = gridRect.maxY - CGFloat(row + 1) * tileH - CGFloat(row) * gridGap
+ return CGRect(x: x, y: y, width: tileW, height: tileH)
+}
+
+let positions: [(col: Int, row: Int)] = [(0, 0), (1, 0), (0, 1), (1, 1)]
+
+for (i, pos) in positions.enumerated() {
+ let r = tileRect(col: pos.col, row: pos.row)
+ let path = CGPath(roundedRect: r, cornerWidth: tileCorner, cornerHeight: tileCorner, transform: nil)
+
+ // Drop shadow
+ ctx.saveGState()
+ ctx.setShadow(
+ offset: CGSize(width: 0, height: -s * 0.012),
+ blur: s * 0.025,
+ color: CGColor(red: 0, green: 0, blue: 0, alpha: 0.45)
+ )
+ ctx.addPath(path)
+ ctx.setFillColor(tiles[i].color)
+ ctx.fillPath()
+ ctx.restoreGState()
+
+ // Fill with vertical gradient
+ ctx.saveGState()
+ ctx.addPath(path)
+ ctx.clip()
+
+ let tileGradient = CGGradient(
+ colorsSpace: CGColorSpaceCreateDeviceRGB(),
+ colors: [tiles[i].accent, tiles[i].color] as CFArray,
+ locations: [0.0, 1.0]
+ )!
+ ctx.drawLinearGradient(
+ tileGradient,
+ start: CGPoint(x: r.midX, y: r.maxY),
+ end: CGPoint(x: r.midX, y: r.minY),
+ options: []
+ )
+
+ // Title bar
+ let titleBarHeight: CGFloat = tileH * 0.18
+ let titleBarRect = CGRect(x: r.minX, y: r.maxY - titleBarHeight, width: r.width, height: titleBarHeight)
+ ctx.setFillColor(CGColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.22))
+ ctx.fill(titleBarRect)
+
+ // Traffic lights
+ let dotRadius: CGFloat = titleBarHeight * 0.22
+ let dotY = titleBarRect.midY
+ let dotStartX = r.minX + titleBarHeight * 0.6
+ let dotGap = dotRadius * 3.0
+ let dotColors: [CGColor] = [
+ CGColor(red: 1.0, green: 0.36, blue: 0.34, alpha: 1.0),
+ CGColor(red: 1.0, green: 0.74, blue: 0.20, alpha: 1.0),
+ CGColor(red: 0.30, green: 0.82, blue: 0.35, alpha: 1.0),
+ ]
+ for (k, c) in dotColors.enumerated() {
+ let cx = dotStartX + CGFloat(k) * dotGap
+ let dotRect = CGRect(x: cx - dotRadius, y: dotY - dotRadius, width: dotRadius * 2, height: dotRadius * 2)
+ ctx.setFillColor(c)
+ ctx.fillEllipse(in: dotRect)
+ }
+
+ ctx.restoreGState()
+
+ // Subtle stroke
+ ctx.saveGState()
+ ctx.addPath(path)
+ ctx.setStrokeColor(CGColor(red: 0, green: 0, blue: 0, alpha: 0.18))
+ ctx.setLineWidth(s * 0.0025)
+ ctx.strokePath()
+ ctx.restoreGState()
+}
+
+guard let img = ctx.makeImage() else {
+ fputs("makeImage failed\n", stderr)
+ exit(1)
+}
+
+let url = URL(fileURLWithPath: outPath)
+guard let dest = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
+ fputs("destination failed\n", stderr)
+ exit(1)
+}
+CGImageDestinationAddImage(dest, img, nil)
+guard CGImageDestinationFinalize(dest) else {
+ fputs("finalize failed\n", stderr)
+ exit(1)
+}
+
+print("Wrote \(outPath)")