From 7fa502d64110a8e985c8a8a0a9eecad025b556ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=83=9C=EC=9B=85=28Taewoong=2EYoun=29?= Date: Fri, 8 May 2026 01:08:39 +0900 Subject: [PATCH] macOS: add resource browser, migrate menu bar shell to AppKit, fix macOS 26 layout-cycle crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds in-menu access to org resources after connect, replaces SwiftUI MenuBarExtra with an AppKit-hosted menu bar shell, and fixes a hard crash on macOS 26 caused by NavigationSplitView + .windowResizability inside a SwiftUI WindowGroup. Resources feature: - New Models: UserResource, UserSiteResource, GetUserResourcesData, SiteResourceDetail (with siteIds/siteNames/siteOnlines, port ranges, ICMP flag), ListAllSiteResourcesData. - APIClient.listUserResources(orgId:), APIClient.listAllSiteResources(orgId:pageSize:). - ResourceCache (@MainActor ObservableObject): 3-min background polling while connected, manual refresh re-arms the timer, refreshSequence token guards against concurrent refreshes overwriting each other. - Menu UX: Public/Private hover submenus with live search, site grouping for Private list with online indicators, per-row Open / Copy Alias / Copy Address actions with transient feedback, manual Refresh row, "Connect to Pangolin" placeholder when disconnected, detail panel (3rd depth) showing Domain / Destination / Mode / Alias / TCP / UDP / ICMP. Menu-bar architecture (SwiftUI -> AppKit): - MainMenuController: NSStatusItem + custom FocusableMenuPanel (NSPanel) + FirstMouseHostingController. Connected status icon is composited (orange disc + white checkmark, cached). - MenuPanelController: reusable controller for 2-depth submenus and 3-depth detail panels. Anchored NSPanel, key-window transfer on first click via sendEvent override, full deinit cleanup of click / mouse-move monitors, hide timer, and the panel itself. - SubmenuCoordinator: only one HoverSubmenuRow open at a time. - HoverSubmenuRow: hover-delay scheduling, keep-open signal so a 3-depth detail panel keeps its parent submenu open. - AnchorReader (NSViewRepresentable): reports row screen frames; overrides setFrameOrigin/setFrameSize so position-only layout shifts (e.g. logout shrinking the menu) update the anchor instead of leaving submenus aligned to pre-logout coordinates. - CustomSwitch: replaces SwiftUI Toggle(.switch), which dims when its window isn't key. WindowGroup -> AppKit (fixes macOS 26 crash): - AppWindowsController singleton lazily creates NSWindow + NSHostingController for Login, Onboarding, Preferences. All window-level configuration (styleMask, identifier, title, button visibility, content size) is set explicitly at creation, never mutated during a layout pass. - centerOnScreen places windows at horizontal center, slightly above vertical midline of screen.visibleFrame (multi-display friendly). - NSWindowDelegate.windowWillClose updates dock activation policy. - App body reduced to Settings { EmptyView() } (Scene placeholder). - pangolinOpenWindow notification now observed in PangolinAppDelegate and dispatched to AppWindowsController.show(id:); helper postOpenWindow(id:) replaces direct openWindow usage in NSPanel- hosted views. - Removed window-management code from view bodies (WindowAccessor in LoginView, OnboardingWindowAccessor, PreferencesWindowAccessor, configureWindow / hideMenuBarItems / handleWindowAppear etc.). On macOS 26 those ran during the display-cycle observer and threw NSException from _postWindowNeedsUpdateConstraints. - Removed outer .frame(minWidth: 600, minHeight: 400) on PreferencesWindow — combined with NavigationSplitView and .windowResizability(.contentSize) it produced the layout cycle. navigationSplitViewColumnWidth(min:200) still enforces a sane minimum sidebar width. Onboarding UX: - mainContent split: onboardingMenuContent shows minimal menu (Open Pangolin Setup + Quit) during onboarding; fullMenuContent shows the post-onboarding menu. Previously the full menu was visible during setup, which was unintended. Bug fixes: - ResourceCache.refresh() concurrent refresh race: stale results from an older in-flight request can no longer overwrite a newer one's results. - HoverSubmenuRow keepOpenSignal race: .onChange(of:keepOpenSignal) now re-runs scheduleUpdate so a stale close-timer doesn't fire after detail-panel hover state flips. - Status-icon animation timer: each queued Task checks the current tunnelManager.status before applying a loading frame, preventing a frame queued before transition to .connected from overwriting the connected-badge icon. - AuthManager.hasInitialized prevents re-running the full init cycle on every menu-popover open (eliminated "Loading..." flicker). Cleanup: - ~600 lines of dead code removed: legacy NSMenu-based resources, search window, menu-dropdown row, view modes / popover modes enums, redundant @State, dead notifications. - Stale comment ("5-minute interval" -> 3 minutes), unused imports. - Removed file: Pangolin/macOS/UI/Preferences/PreferencesWindowAccessor.swift. Notes: - Functional behavior of VPN, auth, account management, system extension activation, and IPC is unchanged. TunnelManager.swift, system-extension request paths, entitlements, bundle IDs, and the PacketTunnel target are not modified. - Deployment target stays at macOS 14.0; no APIs above that level are introduced. --- CHANGES.md | 229 ++ Pangolin/Shared/APIClient.swift | 27 +- Pangolin/Shared/AuthManager.swift | 14 +- Pangolin/Shared/Model/Models.swift | 85 + Pangolin/macOS/PangolinApp.swift | 694 +++++- Pangolin/macOS/UI/LoginView.swift | 101 +- Pangolin/macOS/UI/MenuBarView.swift | 2068 ++++++++++++++--- Pangolin/macOS/UI/OnboardingFlowView.swift | 55 +- .../UI/Preferences/PreferencesWindow.swift | 144 +- .../PreferencesWindowAccessor.swift | 26 - 10 files changed, 2680 insertions(+), 763 deletions(-) create mode 100644 CHANGES.md delete mode 100644 Pangolin/macOS/UI/Preferences/PreferencesWindowAccessor.swift diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..5186ed3 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,229 @@ +# Pangolin macOS Client — Changes vs. upstream `fosrl/apple` + +This document summarizes everything that diverges from the original +`https://github.com/fosrl/apple.git` checkout. The deployment target stays +**macOS 14.0 (Sonoma)**; nothing here raises the minimum OS. + +## 1. New feature — Resources list in the menu bar + +Original menu bar had no way to see/access org resources after connecting. +A complete resource browser was added inside the menu bar. + +### New API surface (`Pangolin/Shared/`) +- `Models.swift` — `UserResource`, `UserSiteResource`, `GetUserResourcesData`, + `SiteResourceDetail` (with `siteIds`, `siteNames`, `siteOnlines`, + `tcpPortRangeString`, `udpPortRangeString`, `disableIcmp`), + `ListAllSiteResourcesData`. +- `APIClient.swift` — + `listUserResources(orgId:)` → `GET /org/{orgId}/user-resources`, + `listAllSiteResources(orgId:pageSize:)` → `GET /org/{orgId}/site-resources`. + +### Long‑lived `ResourceCache` (in `MenuBarView.swift`) +- `@MainActor ObservableObject` with `@Published` resources, loading, + lastFetched, lastError. +- 3‑minute background polling, only when the tunnel is `.connected`. +- Manual `refresh()` re‑arms the polling timer (no double fetch). +- Token‑based stale‑result guard (`refreshSequence`) — concurrent refreshes + cannot overwrite each other with stale data. + +### Menu UX +- Public / Private submenus with hover‑to‑open behavior. +- Search field with live filtering; auto‑focused on submenu open. +- Site grouping in Private list with online indicators and per‑group counts. +- Sticky pinned section headers with opaque background. +- Resource detail panel (3rd depth) with Open / Copy Alias / Copy Address; + brief "✓ Copied" / "✓ Opened" feedback row after each action. +- Manual Refresh row with timestamp + spinner. +- "Connect to Pangolin" placeholder when disconnected. + +## 2. Menu bar architecture — SwiftUI `MenuBarExtra` → AppKit + +Original used `MenuBarExtra(.window)`. The menu bar host has been rewritten +with AppKit primitives for reliable click handling and uniform behavior at +every panel depth. + +### `PangolinApp.swift` +- `MainMenuController` — `NSStatusItem` + custom `FocusableMenuPanel` + (`NSPanel` subclass) + `FirstMouseHostingController` + (`NSHostingController` subclass with `acceptsFirstMouse = true`). +- Click on the status item toggles the main panel; a global mouse monitor + + 0.25 s mouse‑out timer dismiss on outside interaction. +- Connected‑state status icon badge: composited orange disc with white + checkmark in the bottom‑right (cached image, redraws only on the first + transition). + +### `MenuBarView.swift` +- `MenuPanelController` — reusable controller for 2nd‑depth submenus and + 3rd‑depth detail panels (anchored `NSPanel`, key‑window transfer on first + click via `sendEvent` override). +- `SubmenuCoordinator` — ensures only one `HoverSubmenuRow` is open at a time. +- `HoverSubmenuRow` — hover‑delay scheduling, keep‑open signal for nested + detail panels. +- `AnchorReader` (`NSViewRepresentable`) — reports row screen frames; overrides + `setFrameOrigin` / `setFrameSize` so position‑only layout shifts (e.g. + logout shrinking the menu) update the anchor. +- `CustomSwitch` — replaces SwiftUI's `Toggle(.switch)`, which dims when its + window isn't key. +- `ConnectToggleRow` — status dot + custom switch. +- Section headers ("ACCOUNT", "ORGANIZATION", "RESOURCES"). +- `MenuItemFeedbackRow` — transient action feedback. + +## 3. All SwiftUI `WindowGroup`s → AppKit `NSWindow` + +Triggered by a hard crash on macOS 26: the Preferences window's +`NavigationSplitView` + `.windowResizability(.contentSize)` produced a layout +cycle that threw `NSException` from `_postWindowNeedsUpdateConstraints`. + +### `AppWindowsController` (new singleton) +- Lazily creates and caches `NSWindow` + `NSHostingController` for: Login, + Onboarding, Preferences. +- All window‑level configuration (styleMask, identifier, title, button + visibility, content size) is set explicitly at creation — never mutated + during a layout pass. +- `centerOnScreen(_:)` helper places windows at the horizontal center, + slightly above the vertical midline of `screen.visibleFrame` (works + correctly on multi‑display setups). +- `NSWindowDelegate.windowWillClose` updates the dock activation policy. + +### `PangolinApp` body reduced to `Settings { EmptyView() }` +SwiftUI's `App` protocol still requires one Scene; the `Settings` scene is +the standard "no main window" idiom for menu‑bar apps. + +### Removed +- `OpenWindowBridge` (hidden 1×1 SwiftUI WindowGroup that proxied + `openWindow`). +- `WindowAccessor` (LoginView), `OnboardingWindowAccessor`, + `PreferencesWindowAccessor` (file fully deleted). +- `configureWindow(_:)` / `configureOnboardingWindow(_:)` methods that + synchronously mutated `styleMask` during SwiftUI body evaluation. +- Inline `setActivationPolicy(.regular)` / `.accessory` toggling scattered + across views (centralized in `AppWindowsController`). +- All direct `@Environment(\.openWindow)` usage in `MenuBarView`. +- Outer `.frame(minWidth: 600, minHeight: 400)` on `PreferencesWindow` (the + layout‑cycle source). +- `.onReceive(NSWindow.didBecomeKeyNotification)` synchronous `styleMask` + mutation in `PreferencesWindow` and `LoginView`. + +### Notification bridge +The `pangolinOpenWindow` notification is now observed directly by +`PangolinAppDelegate`, dispatched to `AppWindowsController.show(id:)`. +Helper: global `postOpenWindow(id: String)`. + +## 4. Onboarding UX + +`MenuBarView.mainContent` is now split: +- `onboardingMenuContent` — the minimal menu shown during onboarding: + "Open Pangolin Setup" + Quit only. (Previously the full menu including + Resources / More remained visible during setup, which was unintended.) +- `fullMenuContent` — the post‑onboarding menu. + +## 5. Bug fixes & cleanup + +### Race conditions / lifecycle +- `ResourceCache.refresh()` — token guard against concurrent refreshes. +- `HoverSubmenuRow` — `.onChange(of: keepOpenSignal)` re‑runs `scheduleUpdate` + so a stale close‑timer cannot fire after the detail‑panel hover state flips. +- `MenuPanelController.deinit` — cleans up `clickMonitor`, `mouseMoveMonitor`, + `hideTimer`, and `panel.orderOut` (was only releasing `clickMonitor`). +- Status‑icon animation timer — each queued Task checks the current + `tunnelManager.status` before applying a loading frame, so a frame queued + before transition to `.connected` cannot overwrite the connected‑badge icon. +- `AnchorReader` — position‑only layout changes (e.g. row moves up after + logout shortens the menu) now update the anchor; previously the panel + reopened at the pre‑logout coordinates. +- `AuthManager.hasInitialized` flag prevents repeat `initialize()` on every + menu open (eliminated the "Loading…" flicker). + +### Dead code removed (~600+ lines) +- `OrganizationsMenu`, `AccountsMenu` (replaced by `HoverSubmenuRow` + + popover content). +- `ConnectButtonItem`, `ConnectMenuRow` (replaced by `ConnectToggleRow`). +- `ResourcesMenu`, `PublicResourceItem`, `PrivateResourceItem` (the original + 4‑depth `NSMenu` version). +- `ResourceSearchView`, `ResourceSearchRow`, `AnyResourceItem` (separate + search window). +- `MenuItemDropdown` (only used by the deleted Menu structs). +- `MenuViewMode` enum + `viewMode` `@State`, `ResourcesPopoverMode` enum + + `resourcesPopoverMode` `@State`. +- View‑builder methods: `accountsContent`, `orgsContent`, `moreContent`, + `resourcesRootContent`, `resourcesPopoverContent`, + `resourcesListPopoverContent`, `resourcesListMainContent`. +- Dead listener for `NSMenu.didBeginTrackingNotification` (`NSMenu` no longer + used). +- Duplicate `.pangolinOpenWindow` listener in `MenuBarView` + (`OpenWindowBridge` handled it). +- Unused `import os.log` in `PangolinApp.swift`. +- Stale comment "5‑minute interval" (actual interval is 3 minutes). +- File deleted: `Pangolin/macOS/UI/Preferences/PreferencesWindowAccessor.swift`. + +## 6. Files touched + +| File | Change | +| --- | --- | +| `Pangolin/Shared/Models.swift` | New types for resources | +| `Pangolin/Shared/APIClient.swift` | Two new endpoints | +| `Pangolin/Shared/AuthManager.swift` | `hasInitialized` flag, `try?` on notification add | +| `Pangolin/macOS/PangolinApp.swift` | Major rewrite: `AppServices`, `MainMenuController`, `AppWindowsController`, AppDelegate notification observer; all `WindowGroup`s removed | +| `Pangolin/macOS/UI/MenuBarView.swift` | Effectively rewritten (resources, hover submenus, AnchorReader, panel controllers, ResourceCache, all the AppKit helpers) | +| `Pangolin/macOS/UI/Preferences/PreferencesWindow.swift` | Stripped of all window‑management code (now pure SwiftUI content) | +| `Pangolin/macOS/UI/LoginView.swift` | Removed `WindowAccessor`, `configureWindow`, activation‑policy toggling | +| `Pangolin/macOS/UI/OnboardingFlowView.swift` | Removed `OnboardingWindowAccessor`, `configureOnboardingWindow` | +| `Pangolin/macOS/UI/Preferences/PreferencesWindowAccessor.swift` | **Deleted** | + +## 7. Net diff characteristics + +- **VPN, auth, account management, system extension activation, IPC** — + functional behavior **unchanged**. `TunnelManager.swift`, system‑extension + request paths, entitlements, bundle IDs, and the PacketTunnel target + weren't edited. +- **Visual / interaction shell** is entirely AppKit‑hosted. The SwiftUI views + remain SwiftUI, but they are no longer responsible for their hosting window. +- **macOS 26 compatibility** — the Preferences‑window layout‑cycle crash is + fixed; no remaining synchronous `styleMask` mutation during display‑cycle + observers. + +## 8. macOS version compatibility + +Deployment target stays at **macOS 14.0**. Every API used in the new code +is available at that level or earlier. + +| API | Required | Used in | +| --- | --- | --- | +| `.onChange(of:) { … }` (0‑arg closure) | macOS 14.0 | `MenuBarView.swift` | +| `.onChange(of:) { _, newValue in … }` (2‑arg closure) | macOS 14.0 | `MenuBarView.swift`, `PreferencesWindow.swift` | +| `NavigationSplitView` | macOS 13.0 | `PreferencesWindow.swift` (kept) | +| `.navigationSplitViewColumnWidth(min:ideal:)` | macOS 13.0 | `PreferencesWindow.swift` | +| `LazyVStack(spacing:pinnedViews:)` | macOS 11.0 | `MenuBarView.swift` | +| `Settings { … }` scene | macOS 11.0 | `PangolinApp.swift` | +| `@StateObject`, `@ObservedObject` | macOS 11.0 | throughout | +| `@FocusState` | macOS 12.0 | `MenuBarView.swift` | +| `.task { … }` modifier | macOS 12.0 | `MenuBarView.swift` | +| `Task { @MainActor in … }`, `async/await` | macOS 12.0 | throughout | +| `nonisolated` on functions / `weak var` in actors | Swift 5.5+ | throughout | +| `NSHostingController` / `NSHostingView` subclassing | macOS 10.15 | `MenuBarView.swift` | +| `NSStatusItem`, `NSPanel`, `NSEvent.addGlobalMonitorForEvents` | macOS 10.6+ | `PangolinApp.swift`, `MenuBarView.swift` | +| `NSImage(systemSymbolName:accessibilityDescription:)`, `SymbolConfiguration` | macOS 11.0 | `PangolinApp.swift` (icon badge) | +| `NETunnelProviderManager.loadAllFromPreferences()` (async) | macOS 12.0 | `TunnelManager.swift` (unchanged) | + +### Things that *could* misbehave on older macOS (but won't, because target is 14.0) + +- The 0‑arg / 2‑arg `.onChange(of:)` forms wouldn't compile on macOS 13. The + 1‑arg deprecated form is intentionally not used anywhere. +- `NavigationSplitView`'s layout behavior in `Preferences` is sensitive to + the surrounding window's resizability mode. Outer `.frame(minWidth: + minHeight:)` was intentionally removed because the macOS 26 layout pass + treated it as a hard constraint on `NSHostingView` and triggered the + `_postWindowNeedsUpdateConstraints` exception. The fix is benign on + earlier macOS — `NavigationSplitView`'s own column width minimums still + enforce a sane minimum size. +- `centerOnScreen(_:)` falls back to `window.center()` if `window.screen` + and `NSScreen.main` are both nil (i.e. no displays attached). Not expected + in normal use but covered. + +### macOS 26 specifics resolved + +- `_postWindowNeedsUpdateConstraints` `NSException` from `NSHostingView` + layout — fixed by the AppKit‑hosted window controller plus removal of + synchronous `styleMask` mutation during display‑cycle observers. +- Status‑icon transition race after `Connect` succeeds — fixed by the + status‑guarded animation timer Task. diff --git a/Pangolin/Shared/APIClient.swift b/Pangolin/Shared/APIClient.swift index b58928f..d423178 100644 --- a/Pangolin/Shared/APIClient.swift +++ b/Pangolin/Shared/APIClient.swift @@ -419,8 +419,33 @@ class APIClient: ObservableObject { return try parseResponse(data, response) } + // MARK: - Resources + + /// Lists public and site resources accessible to the current user. + /// Permission filtering is applied server-side. Port info is NOT included. + func listUserResources(orgId: String) async throws -> GetUserResourcesData { + let (data, response) = try await makeRequest( + method: "GET", + path: "/org/\(orgId)/user-resources" + ) + return try parseResponse(data, response) + } + + /// Lists detailed site resources for the org, including TCP/UDP port ranges and ICMP flag. + /// May return 403 if the user lacks the `listSiteResources` action — callers should treat + /// failures as best-effort and degrade gracefully (i.e. fall back to data from /user-resources). + func listAllSiteResources(orgId: String, pageSize: Int = 100) async throws -> [SiteResourceDetail] { + let (data, response) = try await makeRequest( + method: "GET", + path: "/org/\(orgId)/site-resources", + queryParams: ["pageSize": String(pageSize)] + ) + let wrapped: ListAllSiteResourcesData = try parseResponse(data, response) + return wrapped.siteResources + } + // MARK: - Server Info - + func getServerInfo() async throws -> ServerInfo { let (data, response) = try await makeRequest(method: "GET", path: "/server-info") return try parseResponse(data, response) diff --git a/Pangolin/Shared/AuthManager.swift b/Pangolin/Shared/AuthManager.swift index 2482f2a..b82daf3 100644 --- a/Pangolin/Shared/AuthManager.swift +++ b/Pangolin/Shared/AuthManager.swift @@ -14,6 +14,9 @@ class AuthManager: ObservableObject { @Published var currentOrg: Organization? @Published var organizations: [Organization] = [] @Published var isInitializing = true + /// Set to true after initialize() runs successfully. Prevents repeated re-init when the + /// MenuBarExtra .window popover re-fires onAppear on every open. + @Published var hasInitialized = false @Published var errorMessage: String? @Published var deviceAuthCode: String? @Published var deviceAuthLoginURL: String? @@ -56,8 +59,15 @@ class AuthManager: ObservableObject { } func initialize() async { + // Avoid running the full init cycle every time the menu bar popover re-appears. + // Logout/session-expired flows reset hasInitialized so initialize() can run again. + if hasInitialized { return } + isInitializing = true - defer { isInitializing = false } + defer { + isInitializing = false + hasInitialized = true + } isServerDown = false @@ -843,6 +853,8 @@ class AuthManager: ObservableObject { errorMessage = nil deviceAuthCode = nil deviceAuthLoginURL = nil + // Allow initialize() to run again on next app/menu activation. + hasInitialized = false } } } diff --git a/Pangolin/Shared/Model/Models.swift b/Pangolin/Shared/Model/Models.swift index 333c9d1..64d2463 100644 --- a/Pangolin/Shared/Model/Models.swift +++ b/Pangolin/Shared/Model/Models.swift @@ -378,6 +378,91 @@ struct UpdateMetadataResponse: Codable { let status: String } +// MARK: - User Resources (GET /org/{orgId}/user-resources) + +struct UserResource: Codable, Identifiable, Hashable { + let resourceId: Int + let name: String + let domain: String // e.g. "https://app.example.com" + let enabled: Bool + let isProtected: Bool // true if any of SSO / password / pincode / whitelist is enabled + let resourceProtocol: String // "http" | "tcp" | "udp" | ... + let sso: Bool? + let password: Bool? + let pincode: Bool? + let whitelist: Bool? + + enum CodingKeys: String, CodingKey { + case resourceId, name, domain, enabled + case isProtected = "protected" + case resourceProtocol = "protocol" + case sso, password, pincode, whitelist + } + + var id: Int { resourceId } +} + +struct UserSiteResource: Codable, Identifiable, Hashable { + let siteResourceId: Int + let name: String + let destination: String + let mode: String // "host" | "cidr" | "http" + let scheme: String? // server exposes this under the "protocol" key (maps to siteResources.scheme) + let ssl: Bool + let fullDomain: String? + let enabled: Bool + let alias: String? + let aliasAddress: String? + + enum CodingKeys: String, CodingKey { + case siteResourceId, name, destination, mode + case scheme = "protocol" + case ssl, fullDomain, enabled, alias, aliasAddress + } + + var id: Int { siteResourceId } +} + +struct GetUserResourcesData: Codable { + let resources: [UserResource] + let siteResources: [UserSiteResource] +} + +// MARK: - Site Resource Detail (GET /org/{orgId}/site-resources) +// Includes port info. Used to augment the /user-resources response. + +struct SiteResourceDetail: Codable, Identifiable, Hashable { + let siteResourceId: Int + let name: String + let mode: String + let destination: String + let scheme: String? + let ssl: Bool + let fullDomain: String? + let alias: String? + let aliasAddress: String? + let tcpPortRangeString: String? + let udpPortRangeString: String? + let disableIcmp: Bool? + let enabled: Bool + // Site (network) info — present from /org/{orgId}/site-resources. A site resource can + // belong to multiple sites; we use the first as primary for grouping/display. + let siteIds: [Int]? + let siteNames: [String]? + let siteNiceIds: [String]? + let siteOnlines: [Bool]? + + var id: Int { siteResourceId } + + var primarySiteName: String? { siteNames?.first } + var primarySiteOnline: Bool { siteOnlines?.first ?? false } +} + +struct ListAllSiteResourcesData: Codable { + let siteResources: [SiteResourceDetail] + let pagination: Pagination? +} + // MARK: - Server Info struct ServerInfo: Codable { diff --git a/Pangolin/macOS/PangolinApp.swift b/Pangolin/macOS/PangolinApp.swift index 7c1e8a1..d6f4edc 100644 --- a/Pangolin/macOS/PangolinApp.swift +++ b/Pangolin/macOS/PangolinApp.swift @@ -1,57 +1,33 @@ import AppKit +import Combine import Sparkle import SwiftUI -import os.log #if os(macOS) -struct MenuBarIconView: View { - @ObservedObject var tunnelManager: TunnelManager - private var tunnelStatus: TunnelStatus { - tunnelManager.status - } - - private var isInIntermediateState: Bool { - switch tunnelStatus { - case .starting, .registering: - return true - default: - return false - } - } +// MARK: - AppServices (shared bridge between SwiftUI App and AppDelegate) - var body: some View { - if tunnelStatus == .connected { - Image("MenuBarIcon") - .renderingMode(.template) - } else if isInIntermediateState { - AnimatedLoadingIcon() - } else { - Image("MenuBarIconDimmed") - .renderingMode(.template) - } - } +@MainActor +final class AppServices { + static let shared = AppServices() + weak var configManager: ConfigManager? + weak var secretManager: SecretManager? + weak var accountManager: AccountManager? + weak var apiClient: APIClient? + weak var authManager: AuthManager? + weak var tunnelManager: TunnelManager? + weak var onboardingViewModel: MacOnboardingViewModel? + var updater: SPUUpdater? + let resourceCache = ResourceCache() + private init() {} } -struct AnimatedLoadingIcon: View { - @State private var currentFrame = 1 - - private let frameNames = ["MenuBarIconLoading1", "MenuBarIconLoading2", "MenuBarIconLoading3"] - - var body: some View { - Image(frameNames[currentFrame - 1]) - .renderingMode(.template) - .task { - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds - currentFrame = (currentFrame % 3) + 1 - } - } - } -} +// MARK: - PangolinApp (SwiftUI App) @main struct PangolinApp: App { + @NSApplicationDelegateAdaptor(PangolinAppDelegate.self) var appDelegate + @StateObject private var configManager = ConfigManager() @StateObject private var secretManager = SecretManager() @StateObject private var accountManager = AccountManager() @@ -64,7 +40,6 @@ struct PangolinApp: App { private let updaterController: SPUStandardUpdaterController init() { - // Initialize Sparkle updater updaterController = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) let configMgr = ConfigManager() @@ -72,28 +47,24 @@ struct PangolinApp: App { let accountMgr = AccountManager() let activeAccount = accountMgr.activeAccount - let hostname = activeAccount?.hostname ?? ConfigManager.defaultHostname - let token = - activeAccount.flatMap { acct in - secretMgr.getSessionToken(userId: acct.userId) - } ?? "" + let token = activeAccount.flatMap { acct in + secretMgr.getSessionToken(userId: acct.userId) + } ?? "" let client = APIClient(baseURL: hostname, sessionToken: token) let authMgr = AuthManager( apiClient: client, configManager: configMgr, accountManager: accountMgr, - secretManager: secretMgr, + secretManager: secretMgr ) let tunnelMgr = TunnelManager( configManager: configMgr, accountManager: accountMgr, secretManager: secretMgr, - authManager: authMgr, + authManager: authMgr ) - - // Set tunnel manager reference in auth manager for org switching authMgr.tunnelManager = tunnelMgr let onboardingState = OnboardingStateManager() @@ -111,93 +82,570 @@ struct PangolinApp: App { _tunnelManager = StateObject(wrappedValue: tunnelMgr) _onboardingStateManager = StateObject(wrappedValue: onboardingState) _onboardingViewModel = StateObject(wrappedValue: onboardingVM) + + // Publish managers to AppServices for AppDelegate to consume. + let services = AppServices.shared + services.configManager = configMgr + services.secretManager = secretMgr + services.accountManager = accountMgr + services.apiClient = client + services.authManager = authMgr + services.tunnelManager = tunnelMgr + services.onboardingViewModel = onboardingVM + services.updater = updaterController.updater + + // Wire ResourceCache so background polling can run. + services.resourceCache.apiClient = client + services.resourceCache.authManager = authMgr + services.resourceCache.tunnelManager = tunnelMgr } var body: some Scene { - MenuBarExtra { - MenuBarView( - configManager: configManager, - accountManager: accountManager, - apiClient: apiClient, - authManager: authManager, - tunnelManager: tunnelManager, - updater: updaterController.updater, - onboardingViewModel: onboardingViewModel - ) - .onAppear { - // Set activation policy to accessory (menu bar only) when not showing onboarding - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard !onboardingViewModel.isPresenting, NSApp.activationPolicy() != .accessory else { return } - NSApp.setActivationPolicy(.accessory) - } + // The app no longer uses SwiftUI WindowGroups. All windows (login, onboarding, + // preferences) are managed by AppWindowsController as plain NSWindows hosting + // their respective SwiftUI views via NSHostingController. This avoids the + // SwiftUI WindowGroup + NavigationSplitView layout-cycle crash on macOS 26 + // (_postWindowNeedsUpdateConstraints NSException) and keeps the entire UI + // shell on AppKit for consistency with the menu bar. + // + // The Settings scene is required because SwiftUI's App protocol must declare + // at least one Scene. We never open it; it's the canonical "no main window" + // pattern for menu-bar apps. + Settings { EmptyView() } + } +} - Task { - await authManager.initialize() - } +// MARK: - AppWindowsController +// +// AppKit-managed windows for the SwiftUI views that previously lived in WindowGroups. +// Each window is created lazily on first show, hidden on close (not deallocated), and +// shown again on subsequent requests. Activation policy is updated based on whether +// any of the managed windows are currently visible. + +@MainActor +final class AppWindowsController: NSObject, NSWindowDelegate { + static let shared = AppWindowsController() + + private var loginWindow: NSWindow? + private var onboardingWindow: NSWindow? + private var preferencesWindow: NSWindow? + + func show(id: String) { + switch id { + case "main": showLogin() + case "onboarding": showOnboarding() + case "preferences": showPreferences() + default: break + } + } + + private func showLogin() { + if let w = loginWindow { + w.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + updateActivationPolicy() + return + } + guard + let auth = AppServices.shared.authManager, + let acct = AppServices.shared.accountManager, + let cfg = AppServices.shared.configManager, + let api = AppServices.shared.apiClient + else { return } + let view = LoginView( + authManager: auth, accountManager: acct, configManager: cfg, apiClient: api + ) + let host = NSHostingController(rootView: view) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 440, height: 300), + styleMask: [.titled, .closable, .fullSizeContentView], + backing: .buffered, defer: false + ) + window.contentViewController = host + window.title = "Pangolin" + window.identifier = NSUserInterfaceItemIdentifier("main") + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + window.isReleasedWhenClosed = false + window.delegate = self + centerOnScreen(window) + loginWindow = window + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + updateActivationPolicy() + } + + private func showOnboarding() { + if let w = onboardingWindow { + w.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + updateActivationPolicy() + return + } + guard let vm = AppServices.shared.onboardingViewModel else { return } + let view = MacOnboardingFlowView(viewModel: vm) + let host = NSHostingController(rootView: view) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 560, height: 520), + styleMask: [.titled, .closable, .fullSizeContentView], + backing: .buffered, defer: false + ) + window.contentViewController = host + window.title = "Pangolin Setup" + window.identifier = NSUserInterfaceItemIdentifier("onboarding") + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isReleasedWhenClosed = false + window.delegate = self + centerOnScreen(window) + onboardingWindow = window + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + updateActivationPolicy() + } + + private func showPreferences() { + if let w = preferencesWindow { + w.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + updateActivationPolicy() + return + } + guard + let cfg = AppServices.shared.configManager, + let tm = AppServices.shared.tunnelManager + else { return } + let view = PreferencesWindow(configManager: cfg, tunnelManager: tm) + let host = NSHostingController(rootView: view) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false + ) + window.contentViewController = host + window.title = "Preferences" + window.identifier = NSUserInterfaceItemIdentifier("preferences") + window.isReleasedWhenClosed = false + window.delegate = self + window.setContentSize(NSSize(width: 800, height: 600)) + centerOnScreen(window) + preferencesWindow = window + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + updateActivationPolicy() + } + + /// Centers a window horizontally on the active screen and places it slightly + /// above the vertical center (so it doesn't clip on tall ultra-wide displays + /// or feel buried behind the dock). Replaces `window.center()`, which on + /// macOS pins the window's top-third to the screen midline — visually fine + /// on a single laptop display but unbalanced on large external monitors. + private func centerOnScreen(_ window: NSWindow) { + let screen = window.screen ?? NSScreen.main + guard let visible = screen?.visibleFrame else { + window.center() + return + } + let size = window.frame.size + let x = visible.midX - size.width / 2 + // Sit ~10% above the visible center for a more "natural" placement. + let y = visible.midY - size.height / 2 + visible.height * 0.1 + window.setFrameOrigin(NSPoint(x: x, y: y)) + } + + /// Toggles the dock icon based on whether any managed window is currently visible. + private func updateActivationPolicy() { + let anyVisible = [loginWindow, onboardingWindow, preferencesWindow] + .compactMap { $0 } + .contains { $0.isVisible } + if anyVisible { + if NSApp.activationPolicy() != .regular { + NSApp.setActivationPolicy(.regular) + } + } else { + if NSApp.activationPolicy() != .accessory { + NSApp.setActivationPolicy(.accessory) + } + } + } + + // MARK: NSWindowDelegate + + nonisolated func windowWillClose(_ notification: Notification) { + Task { @MainActor in + // Defer the policy update so the window's `isVisible` reflects the close. + DispatchQueue.main.async { [weak self] in + self?.updateActivationPolicy() + } + } + } +} + +// MARK: - PangolinAppDelegate + +@MainActor +final class PangolinAppDelegate: NSObject, NSApplicationDelegate { + private var menuController: MainMenuController? + private var openWindowObserver: NSObjectProtocol? + + func applicationDidFinishLaunching(_ notification: Notification) { + // Hide dock icon by default; window opens may flip this to .regular. + NSApp.setActivationPolicy(.accessory) + + // Trigger initial auth (if account exists). + Task { @MainActor in + await AppServices.shared.authManager?.initialize() + } + + // Start background resource polling (only fetches when VPN is connected, + // so we don't hammer the API while disconnected). + AppServices.shared.resourceCache.startPolling() + + // Build the menu bar UI. + menuController = MainMenuController() + + // Route window-open notifications (posted by NSPanel-hosted menu UI) to + // the AppKit-managed window controller. Replaces the previous SwiftUI + // OpenWindowBridge scene. + openWindowObserver = NotificationCenter.default.addObserver( + forName: .pangolinOpenWindow, object: nil, queue: .main + ) { notif in + guard let id = notif.userInfo?["id"] as? String else { return } + Task { @MainActor in + AppWindowsController.shared.show(id: id) + } + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + false + } + + deinit { + if let obs = openWindowObserver { + NotificationCenter.default.removeObserver(obs) + } + } +} + +/// SwiftUI background that uses NSVisualEffectView for the standard menu/panel +/// frosted material — matches the appearance of MenuBarExtra .window popovers. +struct MenuPanelVisualEffectBackground: NSViewRepresentable { + var material: NSVisualEffectView.Material = .menu + var blendingMode: NSVisualEffectView.BlendingMode = .behindWindow + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + view.isEmphasized = true + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + } +} + +// MARK: - MainMenuController +// +// AppKit-based menu bar controller: NSStatusItem + NSPanel hosting MenuBarView. +// Replaces SwiftUI's MenuBarExtra so that all popovers (1-depth main, 2-depth +// submenus, 3-depth detail) are uniformly NSPanels with consistent key-window +// and click-handling behavior. + +@MainActor +final class MainMenuController: NSObject { + private let statusItem: NSStatusItem + private var panel: FocusableMenuPanel? + private var hostingController: FirstMouseHostingController? + + private var iconCancellable: AnyCancellable? + private var animTimer: Timer? + private var animFrame: Int = 1 + private var lastStatus: TunnelStatus = .disconnected + + private var clickMonitor: Any? + private var hideTimer: Timer? + + override init() { + self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + super.init() + + statusItem.button?.target = self + statusItem.button?.action = #selector(togglePanel(_:)) + statusItem.button?.sendAction(on: [.leftMouseDown]) + updateIcon() + + // Observe tunnel status changes to update the menu bar icon. + if let tunnelManager = AppServices.shared.tunnelManager { + iconCancellable = tunnelManager.$status.sink { [weak self] _ in + Task { @MainActor in self?.updateIcon() } } - } label: { - MenuBarIconView(tunnelManager: tunnelManager) } + } - // Main Window (Login) - WindowGroup("Pangolin", id: "main") { - LoginView( - authManager: authManager, - accountManager: accountManager, - configManager: configManager, - apiClient: apiClient + // MARK: Icon updates + + private func updateIcon() { + guard let tunnelManager = AppServices.shared.tunnelManager else { return } + let status = tunnelManager.status + lastStatus = status + switch status { + case .connected: + stopAnimation() + setConnectedBadgedIcon() + case .starting, .registering: + startAnimation() + default: + stopAnimation() + setIcon(named: "MenuBarIconDimmed") + } + } + + private func setIcon(named name: String) { + let image = NSImage(named: name) + image?.isTemplate = true + statusItem.button?.image = image + } + + /// Builds and applies an icon with a green ✓ badge in the bottom-right corner — + /// gives a distinct connected-state cue similar to Microsoft Teams' status badge. + /// Cached so we don't redraw on every status tick. + private static var cachedConnectedIcon: NSImage? + + private func setConnectedBadgedIcon() { + if let cached = MainMenuController.cachedConnectedIcon { + statusItem.button?.image = cached + return + } + let icon = MainMenuController.makeConnectedBadgedIcon() + MainMenuController.cachedConnectedIcon = icon + statusItem.button?.image = icon + } + + private static func makeConnectedBadgedIcon() -> NSImage? { + guard let base = NSImage(named: "MenuBarIcon") else { return nil } + let size = base.size + let composite = NSImage(size: size, flipped: false) { rect in + // 1. Render the base icon as a template — manually tint to the menu bar's + // current text color so it adapts to light/dark menu bars. + let menuBarTextColor: NSColor = .labelColor + if let tinted = MainMenuController.tintedTemplateImage(base, color: menuBarTextColor) { + tinted.draw(in: rect) + } else { + base.draw(in: rect) + } + + // 2. Bottom-right orange badge (Pangolin brand) with white ring for contrast. + let badgeDiameter: CGFloat = min(size.width, size.height) * 0.42 + let inset: CGFloat = 0 + let badgeRect = NSRect( + x: rect.maxX - badgeDiameter - inset, + y: rect.minY + inset, + width: badgeDiameter, + height: badgeDiameter ) - .handlesExternalEvents(preferring: ["main"], allowing: ["main"]) - .onAppear { - // Ensure window has correct identifier - DispatchQueue.main.async { - if let window = NSApplication.shared.windows.first(where: { - $0.title == "Pangolin" - }) { - window.identifier = NSUserInterfaceItemIdentifier("main") - } + // White ring + NSColor.white.setFill() + NSBezierPath(ovalIn: badgeRect.insetBy(dx: -1, dy: -1)).fill() + // Orange fill + NSColor(srgbRed: 0.95, green: 0.45, blue: 0.16, alpha: 1).setFill() + NSBezierPath(ovalIn: badgeRect).fill() + + // 3. White checkmark inside the badge — sized large so it's clearly + // visible at menu-bar resolution. + if let check = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) { + let checkConfig = NSImage.SymbolConfiguration(pointSize: badgeDiameter * 0.9, weight: .black) + let configured = check.withSymbolConfiguration(checkConfig) ?? check + if let tinted = MainMenuController.tintedTemplateImage(configured, color: .white) { + let checkSize = NSSize(width: badgeDiameter * 0.78, height: badgeDiameter * 0.78) + let checkRect = NSRect( + x: badgeRect.midX - checkSize.width / 2, + y: badgeRect.midY - checkSize.height / 2, + width: checkSize.width, + height: checkSize.height + ) + tinted.draw(in: checkRect) } } + return true + } + // The composite uses real colors (green badge), not a template. + composite.isTemplate = false + return composite + } + + /// Renders a template NSImage filled with the given color, returning a non-template + /// copy. Used to render the base logo and the checkmark with explicit colors. + private static func tintedTemplateImage(_ source: NSImage, color: NSColor) -> NSImage? { + let size = source.size + let result = NSImage(size: size, flipped: false) { rect in + source.draw(in: rect) + color.set() + rect.fill(using: .sourceIn) + return true + } + result.isTemplate = false + return result + } + + private func startAnimation() { + guard animTimer == nil else { return } + animFrame = 1 + setIcon(named: "MenuBarIconLoading\(animFrame)") + animTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self = self else { return } + // Each timer tick queues a Task on main actor. Between scheduling and + // execution, the tunnel status may have transitioned to .connected and + // updateIcon already called setConnectedBadgedIcon. Without this guard, + // the queued Task would overwrite the connected icon with a loading + // frame, leaving the icon stuck at "..." even after connection succeeds. + guard let tm = AppServices.shared.tunnelManager, + tm.status == .starting || tm.status == .registering else { return } + self.animFrame = (self.animFrame % 3) + 1 + self.setIcon(named: "MenuBarIconLoading\(self.animFrame)") + } } - .windowStyle(.hiddenTitleBar) - .defaultSize(width: 440, height: 300) - .windowResizability(.contentSize) - .commands { - CommandGroup(replacing: .newItem) {} + } + + private func stopAnimation() { + animTimer?.invalidate() + animTimer = nil + } + + // MARK: Panel show/hide + + @objc private func togglePanel(_ sender: Any?) { + if panel?.isVisible == true { + hidePanel() + } else { + showPanel() } + } - // Onboarding Window - WindowGroup("Pangolin Setup", id: "onboarding") { - MacOnboardingFlowView(viewModel: onboardingViewModel) - .handlesExternalEvents(preferring: ["onboarding"], allowing: ["onboarding"]) + private func showPanel() { + guard + let configManager = AppServices.shared.configManager, + let accountManager = AppServices.shared.accountManager, + let apiClient = AppServices.shared.apiClient, + let authManager = AppServices.shared.authManager, + let tunnelManager = AppServices.shared.tunnelManager, + let onboardingViewModel = AppServices.shared.onboardingViewModel, + let updater = AppServices.shared.updater + else { + return } - .windowStyle(.hiddenTitleBar) - .defaultSize(width: 560, height: 520) - .windowResizability(.contentSize) - // Preferences Window - WindowGroup("Preferences", id: "preferences") { - PreferencesWindow( - configManager: configManager, - tunnelManager: tunnelManager - ) - .handlesExternalEvents(preferring: ["preferences"], allowing: ["preferences"]) - } - .defaultSize(width: 800, height: 600) - .windowResizability(.contentSize) - .commands { - // Hide all menu bar items for preferences window - CommandGroup(replacing: .appInfo) {} - CommandGroup(replacing: .appSettings) {} - CommandGroup(replacing: .appTermination) {} - CommandGroup(replacing: .newItem) {} - CommandGroup(replacing: .pasteboard) {} - CommandGroup(replacing: .sidebar) {} - CommandGroup(replacing: .textEditing) {} - CommandGroup(replacing: .textFormatting) {} - CommandGroup(replacing: .toolbar) {} - CommandGroup(replacing: .undoRedo) {} + let menuBarView = MenuBarView( + configManager: configManager, + accountManager: accountManager, + apiClient: apiClient, + authManager: authManager, + tunnelManager: tunnelManager, + updater: updater, + onboardingViewModel: onboardingViewModel, + resourceCache: AppServices.shared.resourceCache + ) + let wrapped = AnyView( + menuBarView + .background(MenuPanelVisualEffectBackground()) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + + // Always create a fresh hosting controller / panel for clean state. + let host = FirstMouseHostingController(rootView: wrapped) + host.view.layoutSubtreeIfNeeded() + let size = host.view.fittingSize + + let p = FocusableMenuPanel( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless], + backing: .buffered, + defer: true + ) + p.level = .popUpMenu + p.isOpaque = false + p.backgroundColor = .clear + p.hasShadow = true + p.isMovable = false + p.hidesOnDeactivate = false + p.isReleasedWhenClosed = false + p.becomesKeyOnlyIfNeeded = false + p.allowKey = true + p.contentViewController = host + panel = p + hostingController = host + + // Position below the status item button. + guard let button = statusItem.button, let buttonWindow = button.window else { return } + let buttonScreenFrame = buttonWindow.convertToScreen( + button.convert(button.bounds, to: nil) + ) + let origin = NSPoint( + x: buttonScreenFrame.midX - size.width / 2, + y: buttonScreenFrame.minY - size.height - 4 + ) + p.setFrame(NSRect(origin: origin, size: size), display: true) + // Activate the app so the panel can become key reliably; without this, + // status-item-driven panels often need two clicks because the first one + // only transfers key. + NSApp.activate(ignoringOtherApps: true) + p.makeKeyAndOrderFront(nil) + + installDismissMonitors() + } + + private func hidePanel() { + panel?.orderOut(nil) + panel = nil + hostingController = nil + removeDismissMonitors() + } + + // MARK: Dismiss monitors + + private func installDismissMonitors() { + if clickMonitor == nil { + clickMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { [weak self] _ in + Task { @MainActor in self?.hidePanel() } + } + } + // Mouse-out polling so the popover dismisses promptly when the cursor + // leaves all our windows even if it stops moving outside. + if hideTimer == nil { + hideTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in + Task { @MainActor in self?.checkMouseAndMaybeHide() } + } + } + } + + private func removeDismissMonitors() { + if let m = clickMonitor { + NSEvent.removeMonitor(m) + clickMonitor = nil + } + hideTimer?.invalidate() + hideTimer = nil + } + + private func checkMouseAndMaybeHide() { + guard panel?.isVisible == true else { return } + let mouseLocation = NSEvent.mouseLocation + let inside = NSApp.windows.contains { window in + window.isVisible && window.frame.contains(mouseLocation) + } + if !inside { + hidePanel() } } } + #endif diff --git a/Pangolin/macOS/UI/LoginView.swift b/Pangolin/macOS/UI/LoginView.swift index 6b20423..962148b 100644 --- a/Pangolin/macOS/UI/LoginView.swift +++ b/Pangolin/macOS/UI/LoginView.swift @@ -155,11 +155,13 @@ struct LoginView: View { .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(windowBackgroundColor) - .background( - WindowAccessor { window in - configureWindow(window) - } - ) + // Note: window-level configuration (styleMask, identifier, button visibility, + // activation policy) is handled by AppWindowsController upfront. We removed + // the WindowAccessor + configureWindow + setActivationPolicy logic that used + // to live here because it ran synchronously during the SwiftUI view body / + // didBecomeKeyNotification, mutating the window's styleMask while AppKit was + // mid-layout — which caused a layout-cycle crash on macOS 26 inside + // _postWindowNeedsUpdateConstraints. .onAppear { if authManager.startDeviceAuthImmediately { authManager.startDeviceAuthImmediately = false @@ -170,28 +172,11 @@ struct LoginView: View { performLogin() } } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard NSApp.activationPolicy() != .regular else { return } - NSApp.setActivationPolicy(.regular) - if let window = NSApplication.shared.windows.first(where: { $0.title == "Pangolin" }) { - configureWindow(window) - let duplicates = NSApplication.shared.windows.filter { w in - (w.identifier?.rawValue == "main" || w.title == "Pangolin") && w != window - } - for duplicate in duplicates { - duplicate.close() - } - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - NSApp.activate(ignoringOtherApps: true) - } - } } .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in if let window = notification.object as? NSWindow, window.identifier?.rawValue == "main" { - configureWindow(window) if authManager.startDeviceAuthImmediately { authManager.startDeviceAuthImmediately = false let hostname = accountManager.activeAccount?.hostname ?? "" @@ -227,19 +212,10 @@ struct LoginView: View { } } .onDisappear { - // Reset state when view disappears + // Reset login state when view disappears. + // Activation policy is now managed by AppWindowsController via the + // window's NSWindowDelegate (windowWillClose). resetLoginState() - - // Hide app from dock when window closes (if no other windows) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - let hasOtherWindows = NSApplication.shared.windows.contains { window in - window.isVisible && (window.identifier?.rawValue == "main" || window.identifier?.rawValue == "preferences") - } - if !hasOtherWindows { - guard NSApp.activationPolicy() != .accessory else { return } - NSApp.setActivationPolicy(.accessory) - } - } } } @@ -499,41 +475,6 @@ struct LoginView: View { hasAutoOpenedBrowser = false } - private func configureWindow(_ window: NSWindow) { - // Set identifier if not set - if window.identifier?.rawValue != "main" { - window.identifier = NSUserInterfaceItemIdentifier("main") - } - - // Configure window style: remove minimize and maximize, keep close button - var styleMask = window.styleMask - styleMask.remove([.miniaturizable, .resizable]) - styleMask.insert([.titled, .closable]) - window.styleMask = styleMask - - // Ensure resizing is disabled - window.styleMask.remove(.resizable) - - // Hide minimize and zoom buttons, keep only close button - if let minimizeButton = window.standardWindowButton(.miniaturizeButton) { - minimizeButton.isHidden = true - } - if let zoomButton = window.standardWindowButton(.zoomButton) { - zoomButton.isHidden = true - } - if let closeButton = window.standardWindowButton(.closeButton) { - closeButton.isHidden = false - } - - // Set window to not be resizable - window.isMovableByWindowBackground = false - - // Set window size explicitly - var frame = window.frame - frame.size = NSSize(width: 440, height: 300) - window.setContentSize(frame.size) - } - private func closeWindow() { // Reset login state before closing resetLoginState() @@ -546,25 +487,3 @@ struct LoginView: View { } } -// Helper view to access NSWindow -struct WindowAccessor: NSViewRepresentable { - var callback: (NSWindow) -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - if let window = view.window { - callback(window) - } - } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if let window = nsView.window { - callback(window) - } - } - } -} diff --git a/Pangolin/macOS/UI/MenuBarView.swift b/Pangolin/macOS/UI/MenuBarView.swift index 931ab89..8347ed7 100644 --- a/Pangolin/macOS/UI/MenuBarView.swift +++ b/Pangolin/macOS/UI/MenuBarView.swift @@ -1,7 +1,120 @@ import AppKit +import Combine import Sparkle import SwiftUI +extension Notification.Name { + /// Fired by any UI code that wants to open a managed window (login, onboarding, + /// preferences). The AppDelegate observes this and routes to AppWindowsController, + /// which creates/raises the corresponding NSWindow. We use a notification rather + /// than calling AppWindowsController directly so that callers don't need a + /// reference to it (and so the same code path works regardless of where the + /// caller lives in the view hierarchy). + static let pangolinOpenWindow = Notification.Name("pangolinOpenWindow") +} + +/// Posts the `pangolinOpenWindow` notification with the given window id. +@MainActor +func postOpenWindow(id: String) { + NotificationCenter.default.post( + name: .pangolinOpenWindow, object: nil, userInfo: ["id": id] + ) +} + +// MARK: - ResourceCache (long-lived store with background polling) + +@MainActor +final class ResourceCache: ObservableObject { + @Published private(set) var publicResources: [UserResource] = [] + @Published private(set) var siteResources: [UserSiteResource] = [] + @Published private(set) var siteResourceDetails: [Int: SiteResourceDetail] = [:] + @Published private(set) var isLoading: Bool = false + @Published private(set) var lastFetched: Date? + @Published private(set) var lastError: String? + + weak var apiClient: APIClient? + weak var authManager: AuthManager? + weak var tunnelManager: TunnelManager? + + private var pollingTimer: Timer? + private let pollingInterval: TimeInterval = 180 // 3 minutes + + /// Monotonic counter for the current refresh. Each refresh() call bumps this + /// and captures its value as a token; after every `await` the token is checked + /// against the latest, and stale results are discarded. Prevents an older + /// in-flight refresh from overwriting a newer one's results. + private var refreshSequence: Int = 0 + + /// Starts the polling timer (or resets it if already running). Each call + /// re-arms the timer so the next tick is `pollingInterval` from now. + func startPolling() { + pollingTimer?.invalidate() + pollingTimer = Timer.scheduledTimer( + withTimeInterval: pollingInterval, repeats: true + ) { [weak self] _ in + Task { @MainActor in + await self?.refreshIfConnected() + } + } + } + + func stopPolling() { + pollingTimer?.invalidate() + pollingTimer = nil + } + + /// Refresh only when the tunnel is connected — otherwise we'd just be hammering + /// the server with requests that produce 0 results from the user's perspective. + func refreshIfConnected() async { + guard tunnelManager?.status == .connected else { return } + await refresh() + } + + func refresh() async { + guard let apiClient = apiClient, + let authManager = authManager, + let orgId = authManager.currentOrg?.orgId else { return } + + refreshSequence &+= 1 + let token = refreshSequence + isLoading = true + lastError = nil + + do { + let result = try await apiClient.listUserResources(orgId: orgId) + // Discard if a newer refresh has started while we were awaiting. + guard token == refreshSequence else { return } + publicResources = result.resources + siteResources = result.siteResources + + if !siteResources.isEmpty, + let details = try? await apiClient.listAllSiteResources(orgId: orgId) { + guard token == refreshSequence else { return } + siteResourceDetails = Dictionary( + uniqueKeysWithValues: details.map { ($0.siteResourceId, $0) } + ) + } else { + guard token == refreshSequence else { return } + siteResourceDetails = [:] + } + lastFetched = Date() + } catch { + guard token == refreshSequence else { return } + lastError = "Failed to load resources" + } + + // Only the latest refresh clears the loading state and re-arms the timer. + guard token == refreshSequence else { return } + isLoading = false + // Re-arm the polling timer so the next automatic fetch is `pollingInterval` + // from this refresh, regardless of whether it was triggered by the timer + // itself, the menu opening, or a manual Refresh click. + if pollingTimer != nil { + startPolling() + } + } +} + struct MenuBarView: View { @ObservedObject var configManager: ConfigManager @ObservedObject var accountManager: AccountManager @@ -10,11 +123,32 @@ struct MenuBarView: View { @ObservedObject var tunnelManager: TunnelManager let updater: SPUUpdater @ObservedObject var onboardingViewModel: MacOnboardingViewModel + @ObservedObject var resourceCache: ResourceCache @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel - @Environment(\.openWindow) private var openWindow @State private var menuOpenCount = 0 @State private var isLoggedOut = false + // In-popover navigation state. + @State private var publicSearch: String = "" + @State private var privateSearch: String = "" + @State private var selectedResourceDetail: ResourceListItem? + @State private var detailPopoverHovered: Bool = false + @State private var submenuPanelHovered: Bool = false + @State private var rowAnchorFrames: [String: NSRect] = [:] + @FocusState private var searchFocused: Bool + @StateObject private var submenuCoordinator = SubmenuCoordinator() + @StateObject private var detailPanel = MenuPanelController() + @StateObject private var submenuPanel = MenuPanelController() + + // Read-only proxies that pull from the long-lived ResourceCache so background + // polling updates flow into the UI automatically. + private var publicResources: [UserResource] { resourceCache.publicResources } + private var siteResources: [UserSiteResource] { resourceCache.siteResources } + private var siteResourceDetails: [Int: SiteResourceDetail] { resourceCache.siteResourceDetails } + private var resourcesLoading: Bool { resourceCache.isLoading } + private var resourcesError: String? { resourceCache.lastError } + private var resourcesLastFetched: Date? { resourceCache.lastFetched } + init( configManager: ConfigManager, accountManager: AccountManager, @@ -23,6 +157,7 @@ struct MenuBarView: View { tunnelManager: TunnelManager, updater: SPUUpdater, onboardingViewModel: MacOnboardingViewModel, + resourceCache: ResourceCache, ) { self.configManager = configManager self.accountManager = accountManager @@ -31,221 +166,670 @@ struct MenuBarView: View { self.tunnelManager = tunnelManager self.updater = updater self.onboardingViewModel = onboardingViewModel + self.resourceCache = resourceCache self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater) } var body: some View { - Group { - // When onboarding is needed, show only a minimal menu (don't load full menu) + mainContent + .frame(width: 240) + .onChange(of: selectedResourceDetail?.id) { + handleDetailChange() + } + .onChange(of: submenuCoordinator.openId) { + if submenuCoordinator.openId == nil { + submenuPanel.hide() + } + } + .onDisappear { + // 1-depth (MenuBarExtra .window popover) dismissed — close all child panels. + submenuCoordinator.openId = nil + selectedResourceDetail = nil + submenuPanel.hide() + detailPanel.hide() + } + } + + @MainActor + private func handleDetailChange() { + if let item = selectedResourceDetail { + showDetailPanel(for: item) + } else { + detailPanel.hide() + } + } + + @MainActor + private func showDetailPanel(for item: ResourceListItem) { + // Anchor: prefer the parent submenu panel's right edge + mouse Y. This avoids + // stale AnchorReader frames when the user scrolls inside the 2-depth list. + let anchor: NSRect + if let panelFrame = submenuPanel.currentFrame, submenuPanel.isVisible { + let mouseY = NSEvent.mouseLocation.y + anchor = NSRect( + x: panelFrame.maxX - 4, + y: mouseY - 1, + width: 1, + height: 1 + ) + } else if let cached = rowAnchorFrames[item.id] { + anchor = cached + } else { + return + } + detailPanel.show( + anchor: anchor, + onClickOutside: { [self] in + selectedResourceDetail = nil + } + ) { + resourceDetailContent(item: item) + .onHover { hovering in + detailPopoverHovered = hovering + } + } + } + + @ViewBuilder + private var mainContent: some View { + VStack(spacing: 0) { if onboardingViewModel.isPresenting { - Button("Open Pangolin Setup") { - openWindow(id: "onboarding") + // Minimal menu during onboarding: just the launcher and Quit. Hiding + // the rest avoids inviting interactions (resources, accounts, etc.) + // while the user still has system-extension/VPN setup to complete. + onboardingMenuContent + } else { + fullMenuContent + } + } + // Vertical padding keeps the first/last row's hover-highlight rectangle + // out of the panel's rounded-corner curve, so the corner doesn't clip + // into the highlight (which made the corners look "chipped"). + .padding(.vertical, 6) + .task { + await onboardingViewModel.refreshPages() + if onboardingViewModel.isPresenting, !onboardingViewModel.hasOpenedOnboardingWindowThisSession { + onboardingViewModel.hasOpenedOnboardingWindowThisSession = true + postOpenWindow(id: "onboarding") + await MainActor.run { + NSApp.setActivationPolicy(.regular) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + NSApp.windows.first { $0.title == "Pangolin Setup" }?.makeKeyAndOrderFront(nil) + } + } + } + } + .onChange(of: onboardingViewModel.isPresenting) { _, newValue in + if !newValue { + onboardingViewModel.hasOpenedOnboardingWindowThisSession = false + NSApp.setActivationPolicy(.accessory) + } + } + .onAppear { + // Increment counter to force view recreation and trigger task + menuOpenCount += 1 + } + .onChange(of: authManager.isAuthenticated) { oldValue, newValue in + // Reset logged out state when authentication state changes + if newValue { + isLoggedOut = false + } + } + } + + @ViewBuilder + private var onboardingMenuContent: some View { + MenuItemTextRow(title: "Open Pangolin Setup") { + postOpenWindow(id: "onboarding") + } + MenuItemDivider() + quitRow + } + + @ViewBuilder + private var quitRow: some View { + MenuItemRow(action: { + Task { + await tunnelManager.disconnect() + try? await Task.sleep(nanoseconds: 500_000_000) + await MainActor.run { + NSApplication.shared.terminate(nil) } - } else if authManager.isInitializing { - HStack { - ProgressView() - .scaleEffect(0.7) - Text("Loading...") - .foregroundColor(.secondary) + } + }, label: { + HStack { + Text("Quit") + Spacer() + Text("⌘Q") + .font(.caption) + .opacity(0.6) + } + }) + } + + @ViewBuilder + private var fullMenuContent: some View { + Group { + if authManager.isInitializing { + HStack(spacing: 6) { + ProgressView().scaleEffect(0.6) + Text("Loading...").foregroundColor(.secondary) } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) } else { - // Server down message if authManager.isServerDown { - Text("The server appears to be down.") - .foregroundColor(.secondary) - .disabled(true) - Divider() - } - - // Error message (for non-server-down, non-session-expired errors) - if let errorMessage = authManager.errorMessage, !authManager.isServerDown, !authManager.sessionExpired { - Text(errorMessage) - .foregroundColor(.secondary) - .disabled(true) - Divider() - } - + MenuItemInfoRow(title: "The server appears to be down.") + MenuItemDivider() + } + + if let errorMessage = authManager.errorMessage, + !authManager.isServerDown, !authManager.sessionExpired { + MenuItemInfoRow(title: errorMessage) + MenuItemDivider() + } + if authManager.isAuthenticated && !isLoggedOut { if accountManager.activeAccount != nil { if authManager.sessionExpired { - Text("Account Locked") - .foregroundColor(.secondary) - Button("Log In") { + MenuItemInfoRow(title: "Account Locked") + MenuItemTextRow( + title: "Log In", + disabled: authManager.isDeviceAuthInProgress + ) { authManager.startDeviceAuthImmediately = true openLoginWindow() } - .disabled(authManager.isDeviceAuthInProgress) } else { - Text(tunnelManager.status.displayText) - .foregroundColor(.secondary) - ConnectButtonItem( + ConnectToggleRow( tunnelManager: tunnelManager, - onboardingViewModel: onboardingViewModel, - openWindow: openWindow + onboardingViewModel: onboardingViewModel ) } - Divider() + MenuItemDivider() } } if accountManager.accounts.count > 0 { - AccountsMenu( - authManager: authManager, - accountManager: accountManager, - tunnelManager: tunnelManager, - openLoginWindow: openLoginWindow - ) - .id(menuOpenCount) // Force view recreation to trigger task + MenuItemSectionHeader(title: "Account") + HoverSubmenuRow( + id: "account", + title: activeAccountLabel(), + coordinator: submenuCoordinator, + panelController: submenuPanel, + submenuHoveredBinding: $submenuPanelHovered + ) { + accountsPopoverContent + } + .id(menuOpenCount) .task { - // Handle menu open logic when menu opens (only if authenticated) if authManager.isAuthenticated { await handleMenuOpen() } } } else { - Button("Login") { - openLoginWindow() - } + MenuItemTextRow(title: "Login") { openLoginWindow() } } if authManager.isAuthenticated && !isLoggedOut { - OrganizationsMenu(authManager: authManager, tunnelManager: tunnelManager) + MenuItemSectionHeader(title: "Organization") + HoverSubmenuRow( + id: "org", + title: authManager.currentOrg?.name ?? "Organizations", + coordinator: submenuCoordinator, + panelController: submenuPanel, + submenuHoveredBinding: $submenuPanelHovered + ) { + orgsPopoverContent + } } - } - Divider() - - // More submenu - Menu("More") { - // Support section - Text("Support") - .foregroundColor(.secondary) - - Button("How Pangolin Works") { - openURL("https://docs.pangolin.net/about/how-pangolin-works") + if authManager.isAuthenticated && !isLoggedOut && !authManager.sessionExpired, + authManager.currentOrg != nil { + MenuItemDivider() + let detailActive = (selectedResourceDetail != nil) || detailPopoverHovered + let isConnected = tunnelManager.status == .connected + MenuItemSectionHeader(title: "Resources") + + let publicCount = isConnected ? publicResources.filter { $0.enabled }.count : 0 + let privateCount = isConnected ? siteResources.filter { $0.enabled }.count : 0 + + HoverSubmenuRow( + id: "public", + title: "Public", + trailing: "\(publicCount)", + keepOpenSignal: detailActive + && submenuCoordinator.openId == AnyHashable("public"), + coordinator: submenuCoordinator, + panelController: submenuPanel, + submenuHoveredBinding: $submenuPanelHovered + ) { + ResourceListPanelView( + query: $publicSearch, + selectedDetail: $selectedResourceDetail, + detailPopoverHovered: $detailPopoverHovered, + allItems: publicResources.filter { $0.enabled }.map { .publicItem($0) }, + detailLookup: { detail(for: $0) }, + onOpen: { openInBrowser($0) }, + onCopyAlias: { copyAlias(for: $0) }, + onCopyAddress: { copyAddress(for: $0) }, + onAnchorUpdate: { id, rect in rowAnchorFrames[id] = rect }, + requiresConnection: true, + isConnected: isConnected + ) } - - Button("Documentation") { - openURL("https://docs.pangolin.net/") + .onAppear { + Task { await loadResourcesIfNeeded() } } - - Divider() - - // Copyright - Text("© \(String(Calendar.current.component(.year, from: Date()))) Fossorial, Inc.") - .foregroundColor(.secondary) - - Button("Terms of Service") { - openURL("https://pangolin.net/terms-of-service.html") + HoverSubmenuRow( + id: "private", + title: "Private", + trailing: "\(privateCount)", + keepOpenSignal: detailActive + && submenuCoordinator.openId == AnyHashable("private"), + coordinator: submenuCoordinator, + panelController: submenuPanel, + submenuHoveredBinding: $submenuPanelHovered + ) { + ResourceListPanelView( + query: $privateSearch, + selectedDetail: $selectedResourceDetail, + detailPopoverHovered: $detailPopoverHovered, + allItems: siteResources.filter { $0.enabled }.map { .siteItem($0) }, + detailLookup: { detail(for: $0) }, + onOpen: { openInBrowser($0) }, + onCopyAlias: { copyAlias(for: $0) }, + onCopyAddress: { copyAddress(for: $0) }, + onAnchorUpdate: { id, rect in rowAnchorFrames[id] = rect }, + requiresConnection: true, + isConnected: isConnected + ) } - Button("Privacy Policy") { - openURL("https://pangolin.net/privacy-policy.html") + if isConnected { + ResourcesRefreshRow( + lastFetched: resourcesLastFetched, + isLoading: resourcesLoading, + onRefresh: { + Task { await loadResources() } + } + ) } + } - Divider() - - // Version information - Text( - "Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown")" - ) - .foregroundColor(.secondary) - - Button("Check for Updates", action: updater.checkForUpdates) - .disabled(!checkForUpdatesViewModel.canCheckForUpdates) + MenuItemDivider() - Button("Preferences") { - openPreferencesWindow() - } + HoverSubmenuRow( + id: "more", + title: "More", + coordinator: submenuCoordinator, + panelController: submenuPanel, + submenuHoveredBinding: $submenuPanelHovered + ) { + morePopoverContent } - Divider() + MenuItemDivider() - // Personal license notice if let serverInfo = authManager.serverInfo, serverInfo.build == "enterprise", let licenseType = serverInfo.enterpriseLicenseType, licenseType.lowercased() == "personal" { - Text("Licensed for personal use only.") - .foregroundColor(.secondary) - .disabled(true) + MenuItemInfoRow(title: "Licensed for personal use only.") } - - // Unlicensed enterprise notice if let serverInfo = authManager.serverInfo, serverInfo.build == "enterprise", !serverInfo.enterpriseLicenseValid { - Text("This server is unlicensed.") - .foregroundColor(.secondary) - .disabled(true) + MenuItemInfoRow(title: "This server is unlicensed.") } - - // OSS community edition notice if let serverInfo = authManager.serverInfo, serverInfo.build == "oss", !serverInfo.supporterStatusValid { - Text("Community Edition. Consider supporting.") - .foregroundColor(.secondary) - .disabled(true) + MenuItemInfoRow(title: "Community Edition. Consider supporting.") } - Divider() + quitRow + } + } - // Quit - Button("Quit") { - Task { - // Disconnect tunnel before quitting - await tunnelManager.disconnect() - // Small delay to ensure disconnect completes - try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds - await MainActor.run { - NSApplication.shared.terminate(nil) - } - } + private func detail(for item: ResourceListItem) -> SiteResourceDetail? { + if case .siteItem(let r) = item { + return siteResourceDetails[r.siteResourceId] + } + return nil + } + + private func performPrimary(_ item: ResourceListItem) { + switch item { + case .publicItem: + openInBrowser(item) + case .siteItem(let r): + if r.mode == "http" { + openInBrowser(item) + } else { + copyAlias(for: item) } - .keyboardShortcut("q") } - .task { - await onboardingViewModel.refreshPages() - if onboardingViewModel.isPresenting, !onboardingViewModel.hasOpenedOnboardingWindowThisSession { - onboardingViewModel.hasOpenedOnboardingWindowThisSession = true - openWindow(id: "onboarding") - await MainActor.run { - NSApp.setActivationPolicy(.regular) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - NSApp.windows.first { $0.title == "Pangolin Setup" }?.makeKeyAndOrderFront(nil) + } + + private func openInBrowser(_ item: ResourceListItem) { + var url: URL? + switch item { + case .publicItem(let r): + url = URL(string: r.domain) + case .siteItem(let r): + let scheme = r.ssl ? "https" : (r.scheme ?? "http") + let host: String = r.fullDomain + ?? (r.alias?.isEmpty == false ? r.alias : nil) + ?? r.aliasAddress + ?? r.destination + if !host.isEmpty { + url = URL(string: "\(scheme)://\(host)") + } + } + if let url = url { NSWorkspace.shared.open(url) } + } + + private func copyAlias(for item: ResourceListItem) { + let text: String + switch item { + case .publicItem(let r): + text = r.domain + case .siteItem(let r): + if let a = r.alias, !a.isEmpty { text = a } + else if let aa = r.aliasAddress, !aa.isEmpty { text = aa } + else { text = r.destination } + } + copyToClipboard(text) + } + + private func copyAddress(for item: ResourceListItem) { + let text: String + switch item { + case .publicItem(let r): + text = r.domain + case .siteItem(let r): + if let aa = r.aliasAddress, !aa.isEmpty { text = aa } + else { text = r.destination } + } + copyToClipboard(text) + } + + @MainActor + private func loadResourcesIfNeeded() async { + // Refresh through the long-lived ResourceCache so background polling and + // foreground refreshes stay coherent. + await resourceCache.refresh() + } + + @MainActor + private func loadResources() async { + await resourceCache.refresh() + } + + // MARK: - Hover Popover Content (no BackHeader; rendered inside HoverSubmenuRow's popover) + + @ViewBuilder + private var accountsPopoverContent: some View { + let accounts = Array(accountManager.accounts.values) + let currentUserId = accountManager.activeAccount?.userId + let disable: Bool = { + switch tunnelManager.status { + case .starting, .registering: return true + default: return false + } + }() + + VStack(spacing: 0) { + ForEach(accounts, id: \.userId) { account in + MenuItemRow(action: { + Task { await authManager.switchAccount(userId: account.userId) } + }, label: { + HStack { + Text(formatAccountLabelExternal(account: account, accounts: accounts)) + Spacer() + if currentUserId == account.userId { + Image(systemName: "checkmark").font(.caption) + } } + }, disabled: disable) + } + + MenuItemDivider() + + MenuItemTextRow(title: "Add Account") { openLoginWindow() } + + if accountManager.activeAccount != nil { + MenuItemTextRow(title: "Logout") { + Task { await authManager.logout() } } } } - .onChange(of: onboardingViewModel.isPresenting) { _, newValue in - if !newValue { - onboardingViewModel.hasOpenedOnboardingWindowThisSession = false - NSApp.setActivationPolicy(.accessory) + .padding(.vertical, 6) + .frame(width: 240) + } + + @ViewBuilder + private var orgsPopoverContent: some View { + let orgs = authManager.organizations + let currentOrgId = authManager.currentOrg?.orgId + let disable: Bool = { + switch tunnelManager.status { + case .starting, .registering: return true + default: return false + } + }() + + VStack(spacing: 0) { + if orgs.isEmpty { + MenuItemInfoRow(title: "No organizations") + } else { + ForEach(orgs, id: \.orgId) { org in + MenuItemRow(action: { + Task { await authManager.selectOrganization(org) } + }, label: { + HStack { + Text(org.name) + Spacer() + if currentOrgId == org.orgId { + Image(systemName: "checkmark").font(.caption) + } + } + }, disabled: disable) + } } } - .onAppear { - // Increment counter to force view recreation and trigger task - menuOpenCount += 1 + .padding(.vertical, 6) + .frame(width: 220) + } + + @ViewBuilder + private var morePopoverContent: some View { + VStack(spacing: 0) { + MenuItemInfoRow(title: "Support") + MenuItemTextRow(title: "How Pangolin Works") { + openURL("https://docs.pangolin.net/about/how-pangolin-works") + } + MenuItemTextRow(title: "Documentation") { + openURL("https://docs.pangolin.net/") + } + + MenuItemDivider() + + MenuItemInfoRow(title: "© \(String(Calendar.current.component(.year, from: Date()))) Fossorial, Inc.") + MenuItemTextRow(title: "Terms of Service") { + openURL("https://pangolin.net/terms-of-service.html") + } + MenuItemTextRow(title: "Privacy Policy") { + openURL("https://pangolin.net/privacy-policy.html") + } + + MenuItemDivider() + + MenuItemInfoRow(title: "Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown")") + MenuItemTextRow( + title: "Check for Updates", + disabled: !checkForUpdatesViewModel.canCheckForUpdates + ) { + updater.checkForUpdates() + } + MenuItemTextRow(title: "Preferences") { openPreferencesWindow() } } - .onReceive(NotificationCenter.default.publisher(for: NSMenu.didBeginTrackingNotification)) { - _ in - // Also handle menu open logic when menu begins tracking (menu is opening) - if authManager.isAuthenticated { - Task { - await handleMenuOpen() + .padding(.vertical, 6) + .frame(width: 240) + } + + @ViewBuilder + private func resourceDetailContent(item: ResourceListItem) -> some View { + VStack(alignment: .leading, spacing: 0) { + BackHeader(title: item.name) { + selectedResourceDetail = nil + } + Divider() + + VStack(alignment: .leading, spacing: 6) { + detailInfoRows(for: item) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + + Divider() + + VStack(spacing: 0) { + if hasOpenURL(for: item) { + MenuItemFeedbackRow( + title: "Open in Browser", + feedbackText: "Opened" + ) { + openInBrowser(item) + } + } + if hasAlias(for: item) { + MenuItemFeedbackRow( + title: "Copy Alias", + feedbackText: "Copied" + ) { + copyAlias(for: item) + } + } + MenuItemFeedbackRow( + title: addressActionTitle(for: item), + feedbackText: "Copied" + ) { + copyAddress(for: item) } } + .padding(.bottom, 4) } - .onChange(of: authManager.isAuthenticated) { oldValue, newValue in - // Reset logged out state when authentication state changes - if newValue { - isLoggedOut = false + .frame(width: 260) + } + + @ViewBuilder + private func detailInfoRows(for item: ResourceListItem) -> some View { + switch item { + case .publicItem(let r): + DetailInfoRow(label: "Domain", value: r.domain) + DetailInfoRow(label: "Protocol", value: r.resourceProtocol) + if r.isProtected { + DetailInfoRow(label: "Protected", value: "Yes") + } + case .siteItem(let r): + switch r.mode { + case "http": + if let domain = r.fullDomain, !domain.isEmpty { + DetailInfoRow(label: "Domain", value: domain) + } + DetailInfoRow(label: "Destination", value: r.destination) + case "cidr": + DetailInfoRow(label: "CIDR", value: r.destination) + default: + DetailInfoRow(label: "Address", value: r.destination) + } + if let alias = r.alias, !alias.isEmpty { + DetailInfoRow(label: "Alias", value: alias) + } + if let aliasAddr = r.aliasAddress, !aliasAddr.isEmpty, aliasAddr != r.alias { + DetailInfoRow(label: "Alias IP", value: aliasAddr) + } + DetailInfoRow(label: "Mode", value: r.mode.uppercased()) + if let d = siteResourceDetails[r.siteResourceId] { + if let names = d.siteNames, !names.isEmpty { + HStack(alignment: .firstTextBaseline) { + Text("Site") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .frame(width: 64, alignment: .leading) + Circle() + .fill(d.primarySiteOnline ? Color.green : Color.secondary.opacity(0.5)) + .frame(width: 7, height: 7) + Text(names.joined(separator: ", ")) + .font(.system(size: 12)) + .textSelection(.enabled) + .lineLimit(2) + Spacer(minLength: 0) + } + } + // Always show TCP / UDP / ICMP rows so the user can see at a glance + // which protocols are open, blocked, or unrestricted on the resource. + DetailInfoRow(label: "TCP", value: portValue(d.tcpPortRangeString)) + DetailInfoRow(label: "UDP", value: portValue(d.udpPortRangeString)) + DetailInfoRow( + label: "ICMP", + value: (d.disableIcmp == false) ? "enabled" : "disabled" + ) } } } + /// Formats a port range string for display. Server uses "*" to mean + /// "all ports" and an empty / nil string to mean "no ports allowed". + private func portValue(_ raw: String?) -> String { + guard let raw = raw, !raw.isEmpty else { return "—" } + return raw == "*" ? "all" : raw + } + + private func hasOpenURL(for item: ResourceListItem) -> Bool { + switch item { + case .publicItem: return true + case .siteItem(let r): return r.mode == "http" + } + } + + private func hasAlias(for item: ResourceListItem) -> Bool { + if case .siteItem(let r) = item { + return (r.alias?.isEmpty == false) + } + return false + } + + private func addressActionTitle(for item: ResourceListItem) -> String { + switch item { + case .publicItem: return "Copy URL" + case .siteItem(let r): + return r.mode == "cidr" ? "Copy CIDR" : "Copy Address" + } + } + + // MARK: - Account label helpers + + private func activeAccountLabel() -> String { + guard let active = accountManager.activeAccount else { return "Account" } + let accounts = Array(accountManager.accounts.values) + return formatAccountLabelExternal(account: active, accounts: accounts) + } + + private func formatAccountLabelExternal(account: Account, accounts: [Account]) -> String { + // Show email; if duplicates, append hostname for clarity. + let emailCount = accounts.filter { $0.email == account.email }.count + if emailCount > 1 { + let host = URL(string: account.hostname)?.host ?? account.hostname + return "\(account.email) (\(host))" + } + return account.email + } + private func handleMenuOpen() async { // Check server health first var healthCheckFailed = false @@ -318,303 +902,1091 @@ struct MenuBarView: View { } private func openLoginWindow() { - // Show app in dock when opening window - DispatchQueue.main.async { - guard NSApp.activationPolicy() != .regular else { return } - NSApp.setActivationPolicy(.regular) + postOpenWindow(id: "main") + } + + private func openPreferencesWindow() { + postOpenWindow(id: "preferences") + } +} + +/// Custom switch that doesn't dim when its parent window loses key (unlike the +/// system NSSwitch used by SwiftUI Toggle.toggleStyle(.switch)). +struct CustomSwitch: View { + @Binding var isOn: Bool + var disabled: Bool = false + let action: () -> Void + + private let trackWidth: CGFloat = 30 + private let trackHeight: CGFloat = 18 + private let knobSize: CGFloat = 14 + + var body: some View { + ZStack(alignment: isOn ? .trailing : .leading) { + RoundedRectangle(cornerRadius: trackHeight / 2) + .fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35)) + .frame(width: trackWidth, height: trackHeight) + Circle() + .fill(Color.white) + .frame(width: knobSize, height: knobSize) + .padding(.horizontal, (trackHeight - knobSize) / 2) + .shadow(color: .black.opacity(0.2), radius: 1, y: 0.5) } + .frame(width: trackWidth, height: trackHeight) + .opacity(disabled ? 0.5 : 1) + .animation(.easeInOut(duration: 0.15), value: isOn) + .contentShape(Rectangle()) + .onTapGesture { + guard !disabled else { return } + action() + } + } +} + +/// Status row with a Toggle switch for Connect/Disconnect. +struct ConnectToggleRow: View { + @ObservedObject var tunnelManager: TunnelManager + @ObservedObject var onboardingViewModel: MacOnboardingViewModel - // Find existing window by identifier or title - let existingWindow = NSApplication.shared.windows.first { window in - window.identifier?.rawValue == "main" || window.title == "Pangolin" + private var statusColor: Color { + switch tunnelManager.status { + case .connected: return .green + case .starting, .registering: return .orange + default: return .secondary } + } - if let window = existingWindow { - // Window exists - close any duplicates first - let allMainWindows = NSApplication.shared.windows.filter { w in - (w.identifier?.rawValue == "main" || w.title == "Pangolin") && w != window - } - for duplicateWindow in allMainWindows { - duplicateWindow.close() + private func performToggle() { + Task { @MainActor in + if !tunnelManager.isNEConnected { + await onboardingViewModel.refreshPages() + if onboardingViewModel.isPresenting { + onboardingViewModel.hasOpenedOnboardingWindowThisSession = true + postOpenWindow(id: "onboarding") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + NSApplication.shared.windows.first { $0.title == "Pangolin Setup" }?.makeKeyAndOrderFront(nil) + } + return + } + await tunnelManager.connect() + } else { + await tunnelManager.disconnect() } + } + } - // Configure window - var styleMask = window.styleMask - styleMask.remove([.miniaturizable, .resizable]) - styleMask.insert([.titled, .closable]) - window.styleMask = styleMask + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + Text(tunnelManager.status.displayText) + .font(.system(size: 13, weight: .medium)) + Spacer() + CustomSwitch( + isOn: .constant(tunnelManager.isNEConnected), + disabled: tunnelManager.status == .starting, + action: performToggle + ) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } +} - // Hide minimize and zoom buttons, keep only close button - if let minimizeButton = window.standardWindowButton(.miniaturizeButton) { - minimizeButton.isHidden = true - } - if let zoomButton = window.standardWindowButton(.zoomButton) { - zoomButton.isHidden = true - } - if let closeButton = window.standardWindowButton(.closeButton) { - closeButton.isHidden = false - } +@MainActor +func copyToClipboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) +} - // Bring existing window to front - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - NSApp.activate(ignoringOtherApps: true) +// MARK: - AppKit-backed detail panel +// +// SwiftUI's `.popover` uses NSPopover with `.transient` behavior, which auto-dismisses on +// any click — including clicks inside nested popovers. To get reliable click handling and +// a flat (non-bubble) appearance, the resource detail is shown via a borderless NSPanel +// hosted manually. + +/// Reads the on-screen frame of the SwiftUI view it is attached to. Used to anchor an +/// NSPanel near a SwiftUI row. +struct AnchorReader: NSViewRepresentable { + var onFrame: (NSRect) -> Void + + func makeNSView(context: Context) -> NSView { + let view = AnchorView() + view.onFrameChange = onFrame + return view + } - // Ensure identifier is set - if window.identifier?.rawValue != "main" { - window.identifier = NSUserInterfaceItemIdentifier("main") - } + func updateNSView(_ nsView: NSView, context: Context) { + let view = nsView as? AnchorView + view?.onFrameChange = onFrame + // SwiftUI re-runs body when state changes (e.g. logout shrinks the menu). + // The NSView is reused but its origin may have shifted because rows above + // disappeared. Re-report on every update so the parent's `anchorFrame` + // never sticks to a stale (pre-state-change) position. + DispatchQueue.main.async { [weak view] in view?.report() } + } + + final class AnchorView: NSView { + var onFrameChange: ((NSRect) -> Void)? + + // Pass-through hit testing so this invisible view never intercepts mouse + // events (e.g. .onHover modifiers on the SwiftUI parent). + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + DispatchQueue.main.async { [weak self] in self?.report() } + } + + override func viewDidEndLiveResize() { + super.viewDidEndLiveResize() + report() + } + + override func resizeSubviews(withOldSize oldSize: NSSize) { + super.resizeSubviews(withOldSize: oldSize) + report() + } + + // Catch position-only changes — e.g. when logging out shortens the menu, + // rows above shrink/disappear and this view's origin shifts upward without + // its size changing. None of the resize-/move-to-window hooks above fire + // for that, so anchor frames stale and submenus opened post-logout would + // align to the pre-logout row position. setFrameOrigin/setFrameSize fire + // on every layout pass that touches this view. + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + report() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + report() + } + + fileprivate func report() { + guard let window = self.window else { return } + let inWindow = self.convert(self.bounds, to: nil) + let inScreen = window.convertToScreen(inWindow) + onFrameChange?(inScreen) + } + } +} + +/// NSHostingView subclass that accepts first mouse — important for non-activating +/// NSPanels where SwiftUI gestures otherwise refuse to fire because the window isn't key. +final class FirstMouseHostingView: NSHostingView { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } +} + +/// NSHostingController that uses FirstMouseHostingView so taps fire even in non-key panels. +final class FirstMouseHostingController: NSHostingController { + override func loadView() { + view = FirstMouseHostingView(rootView: rootView) + } +} + +/// NSPanel subclass with a flag controlling whether it can become key window. +/// Panels that contain TextFields (search) need this; panels with only buttons should not, +/// otherwise they steal keyboard focus from sibling panels (e.g. detail panel stealing +/// focus from search panel). +final class FocusableMenuPanel: NSPanel { + /// Whether to call makeKey() on show. Submenu panels with text input do this so the + /// search field is immediately focused. Other panels (e.g. detail) don't, but they + /// can still become key on mouseDown via sendEvent override below — necessary because + /// SwiftUI gesture dispatch only works in key windows. + var allowKey: Bool = true + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + override func sendEvent(_ event: NSEvent) { + if event.type == .leftMouseDown && !isKeyWindow { + // Make key on first click so SwiftUI Buttons / DragGesture fire. + makeKey() + } + super.sendEvent(event) + } +} + +/// Controller for a single floating menu panel (NSPanel) anchored next to a SwiftUI row. +/// Reusable for both 2-depth submenus (Account/Org/Public/Private/More) and 3-depth detail. +@MainActor +final class MenuPanelController: ObservableObject { + private var panel: NSPanel? + private var hostingController: FirstMouseHostingController? + private var clickMonitor: Any? + private var mouseMoveMonitor: Any? + private var hideTimer: Timer? + + var currentFrame: NSRect? { panel?.frame } + var isVisible: Bool { panel?.isVisible == true } + + deinit { + // Defense-in-depth cleanup. Normal path is `hide()` from MenuBarView.onDisappear; + // this catches any other dealloc path. Avoid main actor isolation issues by + // releasing directly (these resources don't require main-thread cleanup). + if let m = clickMonitor { NSEvent.removeMonitor(m) } + if let m = mouseMoveMonitor { NSEvent.removeMonitor(m) } + hideTimer?.invalidate() + panel?.orderOut(nil) + } + + func show( + anchor: NSRect, + onClickOutside: @escaping () -> Void, + requiresKeyboard: Bool = false, + @ViewBuilder content: () -> Content + ) { + let wrapped = AnyView( + content() + .padding(0) + .background(MenuPanelVisualEffectBackground()) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.secondary.opacity(0.15), lineWidth: 0.5) + ) + .compositingGroup() + ) + + if hostingController == nil { + hostingController = FirstMouseHostingController(rootView: wrapped) + } else { + hostingController?.rootView = wrapped + } + guard let host = hostingController else { return } + + // Compute fitting size from SwiftUI content. + host.view.layoutSubtreeIfNeeded() + let size = host.view.fittingSize + + if panel == nil { + let p = FocusableMenuPanel( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless], + backing: .buffered, + defer: true + ) + p.level = .popUpMenu + p.isOpaque = false + p.backgroundColor = .clear + p.hasShadow = true + p.isMovable = false + p.hidesOnDeactivate = false + p.isReleasedWhenClosed = false + // becomesKeyOnlyIfNeeded=true would prevent panels without text input + // from ever becoming key — and SwiftUI gesture dispatch (onTapGesture, + // DragGesture) only fires in key windows. So we explicitly let any panel + // become key, controlled solely by `allowKey` / `requiresKeyboard`. + p.becomesKeyOnlyIfNeeded = false + p.contentViewController = host + panel = p + } + guard let panel = panel as? FocusableMenuPanel else { return } + // All panels can become key — required for SwiftUI gesture dispatch (Button taps, + // DragGesture). The keyboard distinction now matters only for which panel + // currently has text input focus, not for click handling. + panel.allowKey = true + + // Position to the right of the anchor row, vertically aligned to its top. + let panelOrigin = NSPoint( + x: anchor.maxX + 4, + y: anchor.maxY - size.height + ) + panel.setFrame(NSRect(origin: panelOrigin, size: size), display: true) + // Submenu panels (with search) immediately become key so the TextField is + // focused. Detail panels just orderFront — key transfer happens on first + // mouseDown via FocusableMenuPanel.sendEvent so they don't steal focus from + // the search field while the user is just hovering rows. + if requiresKeyboard { + panel.makeKeyAndOrderFront(nil) } else { - // No window exists - open a new one - openWindow(id: "main") - NSApp.activate(ignoringOtherApps: true) + panel.orderFront(nil) + } - // Bring the newly created window to front after it's created - // Use a small delay to ensure the window is created, but check for existence first - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if let window = NSApplication.shared.windows.first(where: { - $0.identifier?.rawValue == "main" || $0.title == "Pangolin" - }) { - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - NSApp.activate(ignoringOtherApps: true) + // Install click-outside monitor (only if not already installed). + if clickMonitor == nil { + clickMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { _ in + // Global monitor sees clicks OUTSIDE the app's windows. Clicks inside + // our panel are local events (not delivered here), so they don't + // dismiss us. + Task { @MainActor in onClickOutside() } + } + } + + // Backup: if mouse stays outside ALL of our windows for a while, hide. + // Mouse-move events only fire on movement, so we need a Timer for the case + // where the mouse stops outside our windows. + if mouseMoveMonitor == nil { + mouseMoveMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.mouseMoved] + ) { [weak self] _ in + guard let self = self else { return } + Task { @MainActor in + self.checkMouseAndScheduleHide(onOutside: onClickOutside) } } + // Also kick off the initial check (in case mouse is already outside). + checkMouseAndScheduleHide(onOutside: onClickOutside) } } - private func openPreferencesWindow() { - // Show app in dock when opening window - DispatchQueue.main.async { - guard NSApp.activationPolicy() != .regular else { return } - NSApp.setActivationPolicy(.regular) + /// On every mouse move (and once at install time), determine whether the cursor is + /// inside one of our visible windows. If outside, arm a one-shot hide timer. + /// If inside, cancel any pending hide. The timer fires regardless of further movement. + private func checkMouseAndScheduleHide(onOutside: @escaping () -> Void) { + let mouseLoc = NSEvent.mouseLocation + let inside = NSApp.windows.contains { w in + w.isVisible && w.frame.contains(mouseLoc) + } + if inside { + hideTimer?.invalidate() + hideTimer = nil + } else if hideTimer == nil { + let timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in + Task { @MainActor in onOutside() } + } + hideTimer = timer } + } - // Find existing preferences window by identifier - let existingWindow = NSApplication.shared.windows.first { window in - window.identifier?.rawValue == "preferences" + func hide() { + panel?.orderOut(nil) + // Discard the panel so the next show() recreates a fresh NSPanel. + // Reusing the same NSPanel across show/hide cycles can leave stale + // event-handling state (key window mishandling, etc.) that makes + // subsequent clicks unreliable. + panel = nil + hostingController = nil + if let m = clickMonitor { + NSEvent.removeMonitor(m) + clickMonitor = nil } + if let m = mouseMoveMonitor { + NSEvent.removeMonitor(m) + mouseMoveMonitor = nil + } + hideTimer?.invalidate() + hideTimer = nil + } +} - if let window = existingWindow { - // Window exists - close any duplicates first - let allPreferencesWindows = NSApplication.shared.windows.filter { w in - w.identifier?.rawValue == "preferences" && w != window - } - for duplicateWindow in allPreferencesWindows { - duplicateWindow.close() +// MARK: - macOS-style Menu Components for .window popover + +/// A clickable menu row with NSMenu-like styling: full-width, left-aligned, hover highlight. +/// Uses .onTapGesture instead of Button because SwiftUI Buttons don't reliably fire +/// inside nonactivating NSPanels (events are delivered, but the button's action handler +/// isn't invoked when the window can't become key). +struct MenuItemRow: View { + let action: () -> Void + @ViewBuilder let label: () -> Label + var disabled: Bool = false + + @State private var hovered: Bool = false + + var body: some View { + label() + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: 26) + .background( + (hovered && !disabled) + ? Color.accentColor.opacity(0.85) + : Color.clear + ) + .foregroundColor(hovered && !disabled ? .white : .primary) + .contentShape(Rectangle()) + .onHover { hovered = $0 } + // DragGesture with minimumDistance=0 fires more reliably than .onTapGesture + // in non-key NSPanels (where SwiftUI's normal tap dispatch is broken). + .gesture( + DragGesture(minimumDistance: 0) + .onEnded { _ in + guard !disabled else { return } + action() + } + ) + } +} + +/// A simple text label menu row. +struct MenuItemTextRow: View { + let title: String + var trailing: String? = nil + var disabled: Bool = false + let action: () -> Void + + var body: some View { + MenuItemRow(action: action, label: { + HStack { + Text(title) + Spacer() + if let trailing = trailing { + Text(trailing).foregroundColor(.secondary) + } } + }, disabled: disabled) + } +} - // Bring existing window to front - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - NSApp.activate(ignoringOtherApps: true) - } else { - // No window exists - open a new one - openWindow(id: "preferences") - NSApp.activate(ignoringOtherApps: true) +/// A menu row that briefly shows a "✓ {feedback}" message after the action runs, +/// then reverts to the normal title. Useful for Copy/Open actions where the user +/// otherwise has no visible confirmation. +struct MenuItemFeedbackRow: View { + let title: String + let feedbackText: String + var disabled: Bool = false + let action: () -> Void - // Bring the newly created window to front after it's created - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if let window = NSApplication.shared.windows.first(where: { - $0.identifier?.rawValue == "preferences" - }) { - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - NSApp.activate(ignoringOtherApps: true) + @State private var showFeedback: Bool = false + @State private var feedbackToken: UUID = UUID() + + var body: some View { + MenuItemRow(action: { + action() + let token = UUID() + feedbackToken = token + withAnimation(.easeIn(duration: 0.12)) { + showFeedback = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { + if feedbackToken == token { + withAnimation(.easeOut(duration: 0.2)) { + showFeedback = false + } } } - } + }, label: { + HStack(spacing: 6) { + if showFeedback { + Image(systemName: "checkmark") + .foregroundColor(.green) + Text(feedbackText) + } else { + Text(title) + } + Spacer() + } + }, disabled: disabled) } } -struct OrganizationsMenu: View { - @ObservedObject var authManager: AuthManager - @ObservedObject var tunnelManager: TunnelManager +/// A menu row that opens a dropdown (Menu). Styled to match menu-item look. +/// Non-clickable info text row (like a disabled/header item). +struct MenuItemInfoRow: View { + let title: String - private var organizations: [Organization] { - authManager.organizations + var body: some View { + Text(title) + .foregroundColor(.secondary) + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) } +} - private var currentOrgId: String? { - authManager.currentOrg?.orgId +/// Thin divider matching menu separator style. +struct MenuItemDivider: View { + var body: some View { + Divider().padding(.vertical, 2) } +} - private var menuTitle: String { - if let currentOrg = authManager.currentOrg { - return currentOrg.name +/// Footer row for the Resources section: shows last-fetched timestamp on the +/// left and a Refresh button on the right. +struct ResourcesRefreshRow: View { + let lastFetched: Date? + let isLoading: Bool + let onRefresh: () -> Void + + @State private var hovered: Bool = false + @State private var clockTick: Date = Date() + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + return f + }() + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss" + return f + }() + + private var timestampText: String { + if isLoading { + return "Refreshing..." } - return "Organizations" + guard let lastFetched = lastFetched else { + return "Never" + } + // Compact: today shows time-only; older shows date+time. + if Calendar.current.isDateInToday(lastFetched) { + return Self.timeFormatter.string(from: lastFetched) + } + return Self.dateFormatter.string(from: lastFetched) } - private var shouldDisableOrgButtons: Bool { - switch tunnelManager.status { - case .starting, .registering: - return true - default: - return false + private var fullTimestampTooltip: String { + guard let lastFetched = lastFetched else { return "Never" } + return Self.dateFormatter.string(from: lastFetched) + } + + var body: some View { + HStack(spacing: 4) { + Text("Updated \(timestampText)") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .lineLimit(1) + .help(fullTimestampTooltip) + Spacer(minLength: 4) + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(hovered ? Color.accentColor : .secondary) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .contentShape(Rectangle()) + .onHover { hovered = $0 } + .onTapGesture { + guard !isLoading else { return } + onRefresh() + } + .help("Refresh resources") } + .padding(.horizontal, 12) + .padding(.top, 5) + .padding(.bottom, 3) } +} + +/// Small uppercase section label for grouping menu items (e.g. "Account", +/// "Resources"). Non-interactive. +struct MenuItemSectionHeader: View { + let title: String var body: some View { - Menu { - // Show organization count - Text( - organizations.count == 1 ? "1 Organization" : "\(organizations.count) Organizations" - ) + Text(title.uppercased()) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.5) .foregroundColor(.secondary) + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, 1) + .frame(maxWidth: .infinity, alignment: .leading) + } +} - Divider() +/// Coordinates submenu visibility so that only one HoverSubmenuRow is open at a time. +@MainActor +final class SubmenuCoordinator: ObservableObject { + @Published var openId: AnyHashable? +} - ForEach(organizations, id: \.orgId) { org in - Button { - Task { - await authManager.selectOrganization(org) - } - } label: { - HStack { - Text(org.name) - if currentOrgId == org.orgId { - Spacer() - Image(systemName: "checkmark") - } - } +/// A menu row that opens a submenu panel (NSPanel) on hover. +/// Coordinated via SubmenuCoordinator so opening one row replaces any other. +struct HoverSubmenuRow: View { + let id: AnyHashable + let title: String + var trailing: String? = nil + /// Optional external "keep-open" signal — used when a nested panel (e.g. resource + /// detail) is currently active so the parent submenu stays open while the user + /// interacts with that nested view. + var keepOpenSignal: Bool = false + @ObservedObject var coordinator: SubmenuCoordinator + @ObservedObject var panelController: MenuPanelController + @Binding var submenuHoveredBinding: Bool + @ViewBuilder var submenu: () -> Submenu + + @State private var rowHovered: Bool = false + @State private var hoverSession: UUID = UUID() + @State private var anchorFrame: NSRect = .zero + + /// `submenuHoveredBinding` is shared across all HoverSubmenuRows (same panel reused). + /// Only count it as "this row's hover" when this row is the currently-open one — + /// otherwise other rows would think the user is hovering their own submenu and trigger + /// an unwanted open. + private var anyHover: Bool { + rowHovered || (isOpen && submenuHoveredBinding) || keepOpenSignal + } + private var isOpen: Bool { coordinator.openId == id } + + var body: some View { + HStack { + Text(title) + Spacer() + if let t = trailing { + Text(t) + .font(.caption) + .opacity(rowHovered ? 0.95 : 0.6) + } + Image(systemName: "chevron.right") + .font(.caption) + .opacity(0.6) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: 26) + .background(rowHovered ? Color.accentColor.opacity(0.85) : Color.clear) + .foregroundColor(rowHovered ? .white : .primary) + .contentShape(Rectangle()) + .background( + AnchorReader { rect in + // Only update the anchor for next show. DO NOT call showPanel() here: + // any layout-triggering state change would re-fire this callback, + // which would recreate the panel content struct and lose @FocusState + // (causing the search field to lose keyboard focus mid-typing). + anchorFrame = rect + } + ) + .onHover { hovering in + rowHovered = hovering + scheduleUpdate() + } + .onChange(of: isOpen) { + if isOpen { + showPanel() + } + // Hide is handled centrally in MenuBarView when openId becomes nil. + } + .onChange(of: submenuHoveredBinding) { + scheduleUpdate() + } + .onChange(of: keepOpenSignal) { + // External keep-open changed (e.g. detail panel opened/closed). Re-evaluate + // so any pending close timer (which captured the previous keepOpenSignal) + // is invalidated and we re-decide with the latest value. + scheduleUpdate() + } + } + + private func showPanel() { + panelController.show( + anchor: anchorFrame, + onClickOutside: { + coordinator.openId = nil + }, + requiresKeyboard: true + ) { + submenu() + .onHover { hovering in + submenuHoveredBinding = hovering + } + } + } + + private func scheduleUpdate() { + let token = UUID() + hoverSession = token + let delay: TimeInterval = anyHover ? 0.25 : 0.4 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + guard hoverSession == token else { return } + if anyHover { + if coordinator.openId != id { + coordinator.openId = id } - .disabled(shouldDisableOrgButtons) + } else if isOpen { + coordinator.openId = nil } - } label: { - Text(menuTitle) } } } -struct AccountsMenu: View { - @ObservedObject var authManager: AuthManager - @ObservedObject var accountManager: AccountManager - @ObservedObject var tunnelManager: TunnelManager +// MARK: - Resource list item (used by ResourceListPanelView and detail panel) - let openLoginWindow: () -> Void +enum ResourceListItem: Identifiable, Hashable { + case publicItem(UserResource) + case siteItem(UserSiteResource) - private var accounts: [Account] { - return Array(accountManager.accounts.values) + var id: String { + switch self { + case .publicItem(let r): return "p-\(r.resourceId)" + case .siteItem(let r): return "s-\(r.siteResourceId)" + } } - private var emailCounts: [String: Int] { - Dictionary(grouping: accounts, by: { $0.email }).mapValues { $0.count } + var name: String { + switch self { + case .publicItem(let r): return r.name + case .siteItem(let r): return r.name + } } - private var currentAccountUserId: String? { - accountManager.activeAccount?.userId + var subtitle: String { + switch self { + case .publicItem(let r): return r.domain + case .siteItem(let r): + if let alias = r.alias, !alias.isEmpty { return alias } + if let aa = r.aliasAddress, !aa.isEmpty { return aa } + return r.destination + } } - private var menuTitle: String { - if let user = authManager.currentUser { - return user.displayName - } - if let activeAccount = accountManager.activeAccount { - return activeAccount.displayName + var iconName: String { + switch self { + case .publicItem(let r): return r.isProtected ? "lock.fill" : "globe" + case .siteItem: return "lock.fill" } - - return "Select Account" } - private var shouldDisableAccountButton: Bool { - switch tunnelManager.status { - case .starting, .registering: - return true - default: + func matches(query: String) -> Bool { + let q = query.lowercased() + switch self { + case .publicItem(let r): + return r.name.lowercased().contains(q) + || r.domain.lowercased().contains(q) + case .siteItem(let r): + if r.name.lowercased().contains(q) { return true } + if r.destination.lowercased().contains(q) { return true } + if let v = r.alias?.lowercased(), v.contains(q) { return true } + if let v = r.aliasAddress?.lowercased(), v.contains(q) { return true } + if let v = r.fullDomain?.lowercased(), v.contains(q) { return true } return false } } +} - private func formatAccountLabel(account: Account) -> String { - let displayName = account.displayName - let count = emailCounts[account.email, default: 0] +struct BackHeader: View { + let title: String + let onBack: () -> Void - // If multiple accounts share the same email, show hostname to differentiate - let text = - count > 1 - ? "\(displayName) (\(account.hostname))" - : displayName + var body: some View { + HStack(spacing: 6) { + Button(action: onBack) { + Image(systemName: "chevron.left") + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + Text(title).font(.headline) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } +} - return text +/// Self-contained panel content for Public/Private resource list with search. +/// Lives inside an NSHostingController; uses @Binding so SwiftUI re-renders the panel +/// content when the search query updates (instead of snapshotting once). +struct ResourceListPanelView: View { + @Binding var query: String + @Binding var selectedDetail: ResourceListItem? + @Binding var detailPopoverHovered: Bool + let allItems: [ResourceListItem] + let detailLookup: (ResourceListItem) -> SiteResourceDetail? + let onOpen: (ResourceListItem) -> Void + let onCopyAlias: (ResourceListItem) -> Void + let onCopyAddress: (ResourceListItem) -> Void + let onAnchorUpdate: (String, NSRect) -> Void + /// When false, show a "Please connect to Pangolin" notice instead of the list. + var requiresConnection: Bool = false + var isConnected: Bool = true + + @FocusState private var searchFocused: Bool + + private struct SiteGroup: Identifiable { + let id: String // site name (or "Other") + let online: Bool + let items: [ResourceListItem] } - var body: some View { - Menu { - Text( - "Available Accounts" - ) - .foregroundColor(.secondary) + /// Returns true when this list contains only Site (Private) items, in which case + /// we group by site name. Public lists stay flat. + private func shouldGroupBySite(_ items: [ResourceListItem]) -> Bool { + guard !items.isEmpty else { return false } + return items.allSatisfy { + if case .siteItem = $0 { return true } else { return false } + } + } - Divider() + private func makeSiteGroups(from items: [ResourceListItem]) -> [SiteGroup] { + var order: [String] = [] + var byKey: [String: (online: Bool, items: [ResourceListItem])] = [:] + + for item in items { + var key = "Other" + var online = false + if case .siteItem = item, let detail = detailLookup(item) { + if let name = detail.primarySiteName, !name.isEmpty { + key = name + online = detail.primarySiteOnline + } + } + if byKey[key] == nil { + order.append(key) + byKey[key] = (online, []) + } + byKey[key]?.items.append(item) + if online { + byKey[key]?.online = true + } + } - ForEach(accounts, id: \.userId) { account in - let accountLabelText = formatAccountLabel(account: account) + return order.map { key in + let entry = byKey[key]! + return SiteGroup(id: key, online: entry.online, items: entry.items) + } + } - Button { - Task { - // TODO: switch account impl here - await authManager.switchAccount(userId: account.userId) - } - } label: { - HStack { - Text(accountLabelText) - if currentAccountUserId == account.userId { - Spacer() - Image(systemName: "checkmark") - } + var body: some View { + if requiresConnection && !isConnected { + VStack(spacing: 8) { + Image(systemName: "lock.fill") + .font(.system(size: 24)) + .foregroundColor(.secondary) + Text("Connect to Pangolin") + .font(.system(size: 13, weight: .semibold)) + Text("These resources are only accessible through a connected client.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(20) + .frame(width: 280) + } else { + mainListBody + } + } + + @ViewBuilder + private var mainListBody: some View { + // `filtered` is computed inside body so it re-evaluates whenever @Binding + // `query` changes (which is the whole point of using a struct here). + let filtered: [ResourceListItem] = query.isEmpty + ? allItems + : allItems.filter { $0.matches(query: query) } + let groupBySite = shouldGroupBySite(filtered) + let groups: [SiteGroup] = groupBySite ? makeSiteGroups(from: filtered) : [] + + VStack(spacing: 0) { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundColor(.secondary) + TextField("Search Resources...", text: $query) + .textFieldStyle(.plain) + .focused($searchFocused) + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill").foregroundColor(.secondary) } + .buttonStyle(.plain) } - .disabled(shouldDisableAccountButton) } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.secondary.opacity(0.12)) + .cornerRadius(6) + .padding(.horizontal, 10) + .padding(.top, 6) + .padding(.bottom, 4) + + HStack { + Text(query.isEmpty + ? "\(allItems.count) Resource\(allItems.count == 1 ? "" : "s")" + : "\(filtered.count) of \(allItems.count)") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .padding(.horizontal, 14) + .padding(.bottom, 4) Divider() - Button("Add Account") { - openLoginWindow() - } - - if accountManager.activeAccount != nil { - Button("Logout") { - Task { - await authManager.logout() + if filtered.isEmpty { + Text(query.isEmpty ? "No resources" : "No matches") + .foregroundColor(.secondary) + .padding() + } else { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + if groupBySite { + ForEach(groups) { group in + Section(header: SiteSectionHeader( + name: group.id, + online: group.online, + count: group.items.count + )) { + ForEach(group.items) { item in + rowView(for: item) + } + } + } + } else { + ForEach(filtered) { item in + rowView(for: item) + } + } } } + .frame(height: 380) } - } label: { - Text(menuTitle) } + .frame(width: 280) + .padding(.bottom, 4) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + searchFocused = true + } + } + } + + @ViewBuilder + private func rowView(for item: ResourceListItem) -> some View { + ResourceRow( + item: item, + onOpen: { onOpen(item) }, + onCopyAlias: { onCopyAlias(item) }, + onCopyAddress: { onCopyAddress(item) }, + selectedDetail: $selectedDetail, + detailPopoverHovered: $detailPopoverHovered + ) + .background( + AnchorReader { rect in + onAnchorUpdate(item.id, rect) + } + ) + Divider().opacity(0.25) } } -struct ConnectButtonItem: View { - @ObservedObject var tunnelManager: TunnelManager - @ObservedObject var onboardingViewModel: MacOnboardingViewModel - var openWindow: OpenWindowAction +struct SiteSectionHeader: View { + let name: String + let online: Bool + let count: Int - private var shouldDisableButton: Bool { - return tunnelManager.status == .starting + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(online ? Color.green : Color.secondary.opacity(0.5)) + .frame(width: 7, height: 7) + Text(name) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + Spacer() + Text("\(count)") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + // Opaque background so pinned headers don't bleed through underlying rows + // when the user scrolls. Combine the panel's frosted material with a + // tint color for visual hierarchy. + .background( + ZStack { + MenuPanelVisualEffectBackground() + Color.secondary.opacity(0.18) + } + ) } +} + +struct DetailInfoRow: View { + let label: String + let value: String var body: some View { - Button(tunnelManager.isNEConnected ? "Disconnect" : "Connect") { - Task { @MainActor in - if !tunnelManager.isNEConnected { - await onboardingViewModel.refreshPages() - if onboardingViewModel.isPresenting { - onboardingViewModel.hasOpenedOnboardingWindowThisSession = true - openWindow(id: "onboarding") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - NSApplication.shared.windows.first { $0.title == "Pangolin Setup" }?.makeKeyAndOrderFront(nil) - } - return - } - await tunnelManager.connect() - } else { - await tunnelManager.disconnect() - } + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .frame(width: 64, alignment: .leading) + Text(value) + .font(.system(size: 12)) + .textSelection(.enabled) + .lineLimit(2) + Spacer(minLength: 0) + } + } +} + +struct ResourceRow: View { + let item: ResourceListItem + let onOpen: () -> Void + let onCopyAlias: () -> Void + let onCopyAddress: () -> Void + @Binding var selectedDetail: ResourceListItem? + @Binding var detailPopoverHovered: Bool + + @State private var rowHovered: Bool = false + @State private var hoverSession: UUID = UUID() + + private var isShowingDetail: Bool { selectedDetail?.id == item.id } + private var anyHover: Bool { + // Treat the detail popover as part of "this row" only while it's showing + // for this row, so other rows aren't affected by it. + rowHovered || (isShowingDetail && detailPopoverHovered) + } + + var body: some View { + HStack(spacing: 8) { + Image(systemName: item.iconName) + .foregroundColor(rowHovered || isShowingDetail ? .white : .secondary) + .frame(width: 14) + Text(item.name).lineLimit(1) + Spacer(minLength: 6) + Image(systemName: "chevron.right") + .font(.caption) + .opacity(0.6) + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: 24) + .background( + (rowHovered || isShowingDetail) + ? Color.accentColor.opacity(0.85) + : Color.clear + ) + .foregroundColor(rowHovered || isShowingDetail ? .white : .primary) + .contentShape(Rectangle()) + .onHover { hovering in + rowHovered = hovering + scheduleHoverUpdate() + } + .onChange(of: detailPopoverHovered) { + // Re-evaluate close timer when the detail panel's hover state changes + // (e.g., user mouse leaves the NSPanel area). + if isShowingDetail { + scheduleHoverUpdate() + } + } + .onTapGesture { + // Click also shows detail (alternative to hover for accessibility). + selectedDetail = item + } + .contextMenu { + Button("Open in Browser", action: onOpen) + Button("Copy Alias", action: onCopyAlias) + Button("Copy Address", action: onCopyAddress) + } + } + + private func scheduleHoverUpdate() { + let token = UUID() + hoverSession = token + // With NSPanel-based detail (no SwiftUI .popover), hover state is reliable — + // button clicks inside the panel don't toggle .onHover. So we can safely + // auto-close when neither the row nor the panel is hovered. + let delay: TimeInterval = anyHover ? 0.2 : 0.5 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + guard hoverSession == token else { return } + if anyHover { + selectedDetail = item + } else if isShowingDetail { + selectedDetail = nil } } - .disabled(shouldDisableButton) } } diff --git a/Pangolin/macOS/UI/OnboardingFlowView.swift b/Pangolin/macOS/UI/OnboardingFlowView.swift index aa7136a..ce269af 100644 --- a/Pangolin/macOS/UI/OnboardingFlowView.swift +++ b/Pangolin/macOS/UI/OnboardingFlowView.swift @@ -224,38 +224,21 @@ struct MacOnboardingFlowView: View { } .frame(minWidth: 560, minHeight: 520) .background(windowBackgroundColor) - .background( - OnboardingWindowAccessor { window in - configureOnboardingWindow(window) - } - ) + // Window-level configuration (styleMask, button visibility) is handled by + // AppWindowsController upfront. The OnboardingWindowAccessor + + // configureOnboardingWindow logic that used to live here mutated styleMask + // synchronously during SwiftUI body evaluation, which on macOS 26 races + // with NSHostingView's own layout pass and can crash inside + // _postWindowNeedsUpdateConstraints. .onAppear { DispatchQueue.main.async { - NSApplication.shared.windows.first { $0.title == "Pangolin Setup" }?.makeKeyAndOrderFront(nil) + NSApplication.shared.windows.first { + $0.identifier?.rawValue == "onboarding" + }?.makeKeyAndOrderFront(nil) } } } - private func configureOnboardingWindow(_ window: NSWindow) { - var styleMask = window.styleMask - styleMask.remove([.miniaturizable, .resizable]) - styleMask.insert([.titled, .closable]) - window.styleMask = styleMask - window.styleMask.remove(.resizable) - - if let minimizeButton = window.standardWindowButton(.miniaturizeButton) { - minimizeButton.isHidden = true - } - if let zoomButton = window.standardWindowButton(.zoomButton) { - zoomButton.isHidden = true - } - if let closeButton = window.standardWindowButton(.closeButton) { - closeButton.isHidden = false - } - - window.isMovableByWindowBackground = false - } - @ViewBuilder private var footerConfirmationText: some View { let (show, message) = footerConfirmationState @@ -531,26 +514,6 @@ private struct MacOnboardingCompletionPageContent: View { } } -// MARK: - Window configuration (matches LoginView title bar style) - -private struct OnboardingWindowAccessor: NSViewRepresentable { - var callback: (NSWindow) -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - if let window = view.window { callback(window) } - } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if let window = nsView.window { callback(window) } - } - } -} - // MARK: - VPN Page (content only) private struct MacOnboardingVPNPageContent: View { diff --git a/Pangolin/macOS/UI/Preferences/PreferencesWindow.swift b/Pangolin/macOS/UI/Preferences/PreferencesWindow.swift index bc40b1e..64af9ad 100644 --- a/Pangolin/macOS/UI/Preferences/PreferencesWindow.swift +++ b/Pangolin/macOS/UI/Preferences/PreferencesWindow.swift @@ -1,158 +1,50 @@ import SwiftUI import AppKit +// Note: this view is hosted inside an AppKit-managed NSWindow created by +// AppWindowsController. Window-level configuration (styleMask, identifier, +// title, button visibility, activation policy) is handled there, so this view +// only owns the SwiftUI content. Previous SwiftUI-based window manipulation +// (configureWindow, hideMenuBarItems, PreferencesWindowAccessor) was removed +// because it caused a layout-cycle crash inside NavigationSplitView on macOS 26. + struct PreferencesWindow: View { @ObservedObject var configManager: ConfigManager @ObservedObject var tunnelManager: TunnelManager @State private var selectedSection: PreferencesSection = .preferences - + var body: some View { NavigationSplitView { - // Sidebar PreferencesSidebar(selectedSection: $selectedSection) } detail: { - // Detail view PreferencesDetailView( selectedSection: selectedSection, configManager: configManager, tunnelManager: tunnelManager ) } - .frame(minWidth: 600, minHeight: 400) - .background(PreferencesWindowAccessor { window in - configureWindow(window) - }) - .onAppear { - handleWindowAppear() - } - .onChange(of: selectedSection) { _ in + .onChange(of: selectedSection) { _, _ in updateWindowTitle() } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in - if let window = notification.object as? NSWindow, window.identifier?.rawValue == "preferences" { - configureWindow(window) - hideMenuBarItems() - } - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) { notification in - if let window = notification.object as? NSWindow, window.identifier?.rawValue == "preferences" { - restoreMenuBarItems() - } - } - .onDisappear { - handleWindowDisappear() - restoreMenuBarItems() - } - } - - private func handleWindowAppear() { - // Show app in dock when window appears - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard NSApp.activationPolicy() != .regular else { return } - NSApp.setActivationPolicy(.regular) - - // Ensure window identifier is set and close duplicates - if let window = NSApplication.shared.windows.first(where: { $0.identifier?.rawValue == "preferences" }) { - configureWindow(window) - - // Close any other windows with the same identifier - let duplicates = NSApplication.shared.windows.filter { w in - w.identifier?.rawValue == "preferences" && w != window - } - for duplicate in duplicates { - duplicate.close() - } - } - } - } - - private func handleWindowDisappear() { - // Hide app from dock when window closes (if no other windows) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - let hasOtherWindows = NSApplication.shared.windows.contains { window in - window.isVisible && (window.identifier?.rawValue == "main" || window.identifier?.rawValue == "preferences") - } - if !hasOtherWindows { - guard NSApp.activationPolicy() != .accessory else { return } - NSApp.setActivationPolicy(.accessory) - } - } - } - - private func configureWindow(_ window: NSWindow) { - // Set identifier if not set - if window.identifier?.rawValue != "preferences" { - window.identifier = NSUserInterfaceItemIdentifier("preferences") - } - - // Configure window style: allow close, minimize, and maximize - var styleMask = window.styleMask - styleMask.insert([.titled, .closable, .miniaturizable, .resizable]) - window.styleMask = styleMask - - // Show all buttons - if let minimizeButton = window.standardWindowButton(.miniaturizeButton) { - minimizeButton.isHidden = false - } - if let zoomButton = window.standardWindowButton(.zoomButton) { - zoomButton.isHidden = false - } - if let closeButton = window.standardWindowButton(.closeButton) { - closeButton.isHidden = false + .onAppear { + updateWindowTitle() } - - // Update window title based on current section - updateWindowTitle() - - // Hide menu bar items when preferences window is key - hideMenuBarItems() } - + private func updateWindowTitle() { - if let window = NSApplication.shared.windows.first(where: { $0.identifier?.rawValue == "preferences" }) { + if let window = NSApplication.shared.windows.first(where: { + $0.identifier?.rawValue == "preferences" + }) { window.title = selectedSection.rawValue } } - - private func hideMenuBarItems() { - guard let mainMenu = NSApp.mainMenu else { return } - - // Hide all menu items except the app name (first item) - for (index, menuItem) in mainMenu.items.enumerated() { - if index == 0 { - // Keep the app name menu but hide its submenu items - if let submenu = menuItem.submenu { - for submenuItem in submenu.items { - submenuItem.isHidden = true - } - } - } else { - // Hide all other menu items (File, Edit, View, etc.) - menuItem.isHidden = true - } - } - } - - private func restoreMenuBarItems() { - guard let mainMenu = NSApp.mainMenu else { return } - - // Restore all menu items - for menuItem in mainMenu.items { - menuItem.isHidden = false - if let submenu = menuItem.submenu { - for submenuItem in submenu.items { - submenuItem.isHidden = false - } - } - } - } } // MARK: - Sidebar struct PreferencesSidebar: View { @Binding var selectedSection: PreferencesSection - + var body: some View { List(PreferencesSection.allCases, selection: $selectedSection) { section in Label(section.rawValue, systemImage: section.icon) @@ -168,9 +60,8 @@ struct PreferencesDetailView: View { let selectedSection: PreferencesSection @ObservedObject var configManager: ConfigManager @ObservedObject var tunnelManager: TunnelManager - + var body: some View { - // Content Group { switch selectedSection { case .preferences: @@ -184,4 +75,3 @@ struct PreferencesDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } - diff --git a/Pangolin/macOS/UI/Preferences/PreferencesWindowAccessor.swift b/Pangolin/macOS/UI/Preferences/PreferencesWindowAccessor.swift deleted file mode 100644 index a010691..0000000 --- a/Pangolin/macOS/UI/Preferences/PreferencesWindowAccessor.swift +++ /dev/null @@ -1,26 +0,0 @@ -import SwiftUI -import AppKit - -// Helper view to access NSWindow -struct PreferencesWindowAccessor: NSViewRepresentable { - var callback: (NSWindow) -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - if let window = view.window { - callback(window) - } - } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if let window = nsView.window { - callback(window) - } - } - } -} -