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