Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added Resources/AppIcon.icns
Binary file not shown.
Binary file added Resources/Branding/AppIcon-source.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions Resources/Branding/MenuBarIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
<string>CloudTunnels</string>
<key>CFBundleExecutable</key>
<string>CloudTunnels</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>com.fourninecloud.cloud-tunnels</string>
<key>CFBundleInfoDictionaryVersion</key>
Expand Down
16 changes: 8 additions & 8 deletions Sources/CloudTunnels/App/CloudTunnelsApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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() {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions Sources/CloudTunnels/UI/BrandImages.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading