diff --git a/CLAUDE.md b/CLAUDE.md index 98c0672..5db90f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,10 +46,12 @@ Config: `~/Library/Application Support/CloudTunnels/config.json` (legacy read-on ## Architecture -Three SwiftPM targets in `Package.swift`: +Five SwiftPM targets in `Package.swift`: - **`TunnelCore`** (library) — provider-agnostic models, launchers, and helpers. Pure, Sendable-friendly. All shared logic lives here so both the GUI app and the CLI can reuse it. -- **`CloudTunnels`** (executable) — SwiftUI menu-bar app. Depends on `TunnelCore`. +- **`ProxyHelperShared`** (library) — XPC protocol + DTOs shared between the GUI and the privileged helper. Must stay free of GUI/AppKit imports so the helper can link it. +- **`CloudTunnels`** (executable) — SwiftUI menu-bar app. Depends on `TunnelCore` + `ProxyHelperShared`. +- **`CloudTunnelsProxyHelper`** (executable) — privileged launchd daemon. NSXPC listener bound to a Mach service; owns route-table edits, hosts-file edits, and the local CA / NIO server. Installed via `SMAppService.daemon(plistName:)` from the embedded `Contents/Library/LaunchDaemons/com.fourninecloud.cloud-tunnels.proxy-helper.plist`. First registration triggers the macOS auth dialog routing the user to System Settings → Login Items. See `Sources/CloudTunnels/Core/ProxyHelperInstaller.swift` for the install state machine and `Sources/CloudTunnels/Core/ProxyClient.swift` for the GUI-side XPC client. - **`ctun`** (executable) — ArgumentParser CLI. Reads the same `config.json` the GUI writes and dispatches tunnels through the same `LauncherFactory`. ### Provider extensibility (critical) diff --git a/Makefile b/Makefile index cc7c6ff..ef6e789 100644 --- a/Makefile +++ b/Makefile @@ -135,6 +135,7 @@ app: build download-caddy echo "No vendored caddy; relying on system caddy at runtime."; \ fi @cp Resources/Info.plist $(CONTENTS)/Info.plist + @cp Resources/AppIcon.icns $(RESOURCES_DIR)/AppIcon.icns @cp Resources/LaunchDaemons/$(HELPER_BUNDLE_ID).plist $(LAUNCHDAEMONS_DIR)/ @touch $(APP_BUNDLE) @$(MAKE) sign @@ -259,6 +260,7 @@ app-arm64: build-arm64 @cp $(ARM64_BIN) $(ARM64_APP_BUNDLE)/Contents/MacOS/$(APP_NAME) @cp $(ARM64_HELPER_BIN) $(ARM64_APP_BUNDLE)/Contents/MacOS/$(HELPER_NAME) @cp Resources/Info.plist $(ARM64_APP_BUNDLE)/Contents/Info.plist + @cp Resources/AppIcon.icns $(ARM64_APP_BUNDLE)/Contents/Resources/AppIcon.icns @cp Resources/LaunchDaemons/$(HELPER_BUNDLE_ID).plist $(ARM64_APP_BUNDLE)/Contents/Library/LaunchDaemons/ @codesign --force --options runtime --entitlements $(HELPER_ENTITLEMENTS) --sign "$(SIGN_IDENTITY)" $(ARM64_APP_BUNDLE)/Contents/MacOS/$(HELPER_NAME) @codesign --force --options runtime --deep --entitlements $(APP_ENTITLEMENTS) --sign "$(SIGN_IDENTITY)" $(ARM64_APP_BUNDLE) @@ -271,6 +273,7 @@ app-x86_64: build-x86_64 @cp $(X86_64_BIN) $(X86_64_APP_BUNDLE)/Contents/MacOS/$(APP_NAME) @cp $(X86_64_HELPER_BIN) $(X86_64_APP_BUNDLE)/Contents/MacOS/$(HELPER_NAME) @cp Resources/Info.plist $(X86_64_APP_BUNDLE)/Contents/Info.plist + @cp Resources/AppIcon.icns $(X86_64_APP_BUNDLE)/Contents/Resources/AppIcon.icns @cp Resources/LaunchDaemons/$(HELPER_BUNDLE_ID).plist $(X86_64_APP_BUNDLE)/Contents/Library/LaunchDaemons/ @codesign --force --options runtime --entitlements $(HELPER_ENTITLEMENTS) --sign "$(SIGN_IDENTITY)" $(X86_64_APP_BUNDLE)/Contents/MacOS/$(HELPER_NAME) @codesign --force --options runtime --deep --entitlements $(APP_ENTITLEMENTS) --sign "$(SIGN_IDENTITY)" $(X86_64_APP_BUNDLE) diff --git a/Package.swift b/Package.swift index dd9cf11..963407e 100644 --- a/Package.swift +++ b/Package.swift @@ -31,7 +31,11 @@ let package = Package( "ProxyHelperShared", .product(name: "X509", package: "swift-certificates"), ], - path: "Sources/CloudTunnels" + path: "Sources/CloudTunnels", + resources: [ + .copy("Resources/MenuBarIconTemplate.png"), + .copy("Resources/BrandHeaderLogo.png"), + ] ), .executableTarget( name: "ctun", diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000..d946799 Binary files /dev/null and b/Resources/AppIcon.icns differ diff --git a/Resources/Branding/AppIcon-source.png b/Resources/Branding/AppIcon-source.png new file mode 100644 index 0000000..b80e6df Binary files /dev/null and b/Resources/Branding/AppIcon-source.png differ diff --git a/Resources/Branding/MenuBarIcon.svg b/Resources/Branding/MenuBarIcon.svg new file mode 100644 index 0000000..4eb17e1 --- /dev/null +++ b/Resources/Branding/MenuBarIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/Info.plist b/Resources/Info.plist index 25f8be5..a04aa22 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -8,6 +8,10 @@ CloudTunnels CFBundleExecutable CloudTunnels + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon CFBundleIdentifier com.fourninecloud.cloud-tunnels CFBundleInfoDictionaryVersion diff --git a/Sources/CloudTunnels/App/CloudTunnelsApp.swift b/Sources/CloudTunnels/App/CloudTunnelsApp.swift index 440fa6a..a6506ed 100644 --- a/Sources/CloudTunnels/App/CloudTunnelsApp.swift +++ b/Sources/CloudTunnels/App/CloudTunnelsApp.swift @@ -45,7 +45,8 @@ struct CloudTunnelsApp: App { .environmentObject(toasts) .environmentObject(calendar) } label: { - Image(systemName: menuBarSymbol) + BrandImages.menuBarIcon + .opacity(menuBarActive ? 1.0 : 0.55) } .menuBarExtraStyle(.window) @@ -83,13 +84,12 @@ struct CloudTunnelsApp: App { .defaultPosition(.center) } - private var menuBarSymbol: String { - let active = manager.statuses.values.contains { $0.isActive } - let hasError = manager.statuses.values.contains { - if case .error = $0 { return true } else { return false } - } - if hasError { return "cloud.fill" } - return active ? "cloud.fill" : "cloud" + /// Brighten the menu-bar mark when at least one tunnel is connected / + /// connecting, dim it when fully idle. Errors keep the bright state so + /// the badge dot drawn elsewhere stays the indicator-of-record for failure. + private var menuBarActive: Bool { + manager.statuses.values.contains { $0.isActive } || + manager.statuses.values.contains { if case .error = $0 { return true } else { return false } } } private func activate() { diff --git a/Sources/CloudTunnels/Resources/BrandHeaderLogo.png b/Sources/CloudTunnels/Resources/BrandHeaderLogo.png new file mode 100644 index 0000000..69a4a16 Binary files /dev/null and b/Sources/CloudTunnels/Resources/BrandHeaderLogo.png differ diff --git a/Sources/CloudTunnels/Resources/MenuBarIconTemplate.png b/Sources/CloudTunnels/Resources/MenuBarIconTemplate.png new file mode 100644 index 0000000..69a4a16 Binary files /dev/null and b/Sources/CloudTunnels/Resources/MenuBarIconTemplate.png differ diff --git a/Sources/CloudTunnels/UI/BrandImages.swift b/Sources/CloudTunnels/UI/BrandImages.swift new file mode 100644 index 0000000..fdfe097 --- /dev/null +++ b/Sources/CloudTunnels/UI/BrandImages.swift @@ -0,0 +1,42 @@ +import AppKit +import SwiftUI + +/// Brand assets loaded from the SwiftPM resource bundle (`Bundle.module`). +/// PNGs sourced from `Resources/Branding/MenuBarIcon.svg` (the SQV mark) and +/// `Resources/Branding/AppIcon-source.png`. Both are rendered as **template** +/// images so AppKit recolors them based on menu-bar / popover state instead of +/// using the asset's literal pixel colors. +enum BrandImages { + /// SQV monogram used as the macOS menu-bar status item glyph. + /// `MenuBarExtra` renders its label at the NSImage's *intrinsic* size and + /// largely ignores SwiftUI `.frame(...)` modifiers, so we must size the + /// NSImage to menu-bar dimensions here. 20×15 keeps the SVG's natural + /// 1.35:1 aspect ratio and reads well inside the 22pt menu bar without + /// crowding adjacent extras. + static let menuBarIcon: Image = { + let nsImage = loadTemplate(named: "MenuBarIconTemplate", ext: "png") + nsImage.size = NSSize(width: 20, height: 15) + return Image(nsImage: nsImage) + }() + + /// Same mark, used inline in the popover's brand header next to the + /// wordmark. Sized larger here than the menu-bar variant — the popover + /// has the room and the logo doubles as a brand anchor for the window. + static let brandHeaderLogo: Image = { + let nsImage = loadTemplate(named: "BrandHeaderLogo", ext: "png") + nsImage.size = NSSize(width: 30, height: 22) + return Image(nsImage: nsImage) + }() + + private static func loadTemplate(named name: String, ext: String) -> NSImage { + guard let url = Bundle.module.url(forResource: name, withExtension: ext), + let image = NSImage(contentsOf: url) else { + // Fall back to a SF Symbol so the UI never renders a void — + // this only fires if the resource is missing from the bundle. + return NSImage(systemSymbolName: "cloud.fill", accessibilityDescription: name) + ?? NSImage() + } + image.isTemplate = true + return image + } +} diff --git a/Sources/CloudTunnels/UI/MenuBarView.swift b/Sources/CloudTunnels/UI/MenuBarView.swift index e8abc8b..7acce33 100644 --- a/Sources/CloudTunnels/UI/MenuBarView.swift +++ b/Sources/CloudTunnels/UI/MenuBarView.swift @@ -100,9 +100,11 @@ struct MenuBarView: View { private var brandHeader: some View { HStack(alignment: .center, spacing: 8) { - Text("FOURNINE") - .font(.system(size: 14, weight: .heavy)) - .tracking(1.6) + BrandImages.brandHeaderLogo + .foregroundStyle(.primary) + Text("CLOUD TUNNELS") + .font(.system(size: 13, weight: .heavy)) + .tracking(1.4) .foregroundStyle(.primary) Spacer() Button(action: onOpenHelp) {