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)")