Skip to content

macOS: add resource browser, migrate menu bar shell to AppKit, fix macOS 26 layout-cycle crash#43

Open
DanivosYoun wants to merge 1 commit into
fosrl:devfrom
DanivosYoun:feat/macos-resources-and-appkit-shell
Open

macOS: add resource browser, migrate menu bar shell to AppKit, fix macOS 26 layout-cycle crash#43
DanivosYoun wants to merge 1 commit into
fosrl:devfrom
DanivosYoun:feat/macos-resources-and-appkit-shell

Conversation

@DanivosYoun
Copy link
Copy Markdown

Summary

  • Adds an in-menu Resources browser (Public / Private with hover submenus, live search, site grouping, detail panel) so users can reach org resources without leaving the menu bar.
  • Replaces SwiftUI MenuBarExtra with an AppKit-hosted menu bar shell (NSStatusItem + NSPanel + NSHostingController) for reliable click handling, key-window transfer, and uniform behavior across all panel depths.
  • Replaces all SwiftUI WindowGroups with AppKit NSWindow + NSHostingController managed by an AppWindowsController singleton. This fixes a hard crash on macOS 26 where Preferences's NavigationSplitView + .windowResizability(.contentSize) produced a layout cycle that threw NSException from _postWindowNeedsUpdateConstraints.
  • Several concurrency / lifecycle fixes (token-guarded refresh, AnchorReader position-only updates, panel-controller deinit symmetry, status-icon animation race), ~600 lines of dead-code removed.
  • Deployment target stays at macOS 14.0; no APIs above that level are introduced.
  • VPN, auth, account, system-extension, and IPC behavior is unchangedTunnelManager.swift, system-extension request paths, entitlements, bundle IDs, and the PacketTunnel target are not modified.

A more detailed write-up is included in CHANGES.md.

What's in the diff

New

  • ResourceCache (long-lived @MainActor ObservableObject) — 3-min background poll while connected; refreshSequence token guards against concurrent refreshes overwriting each other with stale data.
  • APIClient.listUserResources(orgId:), APIClient.listAllSiteResources(orgId:pageSize:).
  • New types in Models.swift for resources and site details (incl. TCP/UDP port ranges, ICMP flag, site online state).
  • MainMenuController, MenuPanelController, SubmenuCoordinator, HoverSubmenuRow, AnchorReader, FocusableMenuPanel, FirstMouseHostingController, CustomSwitch, ConnectToggleRow.
  • AppWindowsController — owns Login / Onboarding / Preferences NSWindows; centerOnScreen(_:) helper for multi-display friendly placement; windowWillClose updates dock activation policy.
  • postOpenWindow(id:) notification helper (replaces @Environment(\.openWindow) use in NSPanel-hosted views).

Removed

  • OpenWindowBridge SwiftUI scene (no longer needed).
  • WindowAccessor (LoginView), OnboardingWindowAccessor, PreferencesWindowAccessor (file deleted).
  • configureWindow(_:) / configureOnboardingWindow(_:) methods that synchronously mutated styleMask during SwiftUI body / display-cycle observers.
  • Outer .frame(minWidth: 600, minHeight: 400) on PreferencesWindow.
  • .onReceive(NSWindow.didBecomeKeyNotification) synchronous styleMask mutation in PreferencesWindow / LoginView.
  • Inline setActivationPolicy(.regular) / .accessory toggling scattered across views (centralized in AppWindowsController).
  • All direct @Environment(\.openWindow) usage in MenuBarView.

Bug fixes

  • ResourceCache.refresh() concurrent-refresh race (token guard).
  • HoverSubmenuRow keep-open signal race when 3-depth detail panel toggles detailPopoverHovered (.onChange(of: keepOpenSignal) re-runs scheduling).
  • MenuPanelController.deinit cleans up clickMonitor, mouseMoveMonitor, hideTimer, panel.orderOut (was only releasing clickMonitor).
  • Status-icon animation timer overwrites the connected-badge icon — each queued Task now checks current tunnelManager.status before applying a loading frame.
  • AnchorReader overrides setFrameOrigin / setFrameSize so position-only layout shifts (e.g. logout shrinks the menu) update the anchor instead of leaving submenus aligned to pre-logout coordinates.
  • AuthManager.hasInitialized flag prevents re-running the full init cycle on every menu-popover open (eliminated "Loading…" flicker).

Cleanup

  • ~600 lines of dead code (legacy NSMenu-based resources view, separate Resource Search window, Menu-dropdown row helper, unused MenuViewMode / ResourcesPopoverMode enums, redundant @State, dead notification subscriptions).
  • Stale comment fix ("5-minute interval" → 3 minutes), unused import os.log.

Test plan

  • Builds cleanly against macOS 14 deployment target (verified with xcodebuild Debug, ad-hoc-signed locally).
  • Connect/Disconnect via menu bar toggle — status icon transitions disconnected (animated) → connected badge (orange disc + white checkmark).
  • Open Pangolin Setup / Login / Preferences from menu — windows open, configured correctly, centered on the active screen.
  • Preferences opens on macOS 26 (was crashing before — NSException from _postWindowNeedsUpdateConstraints).
  • Resources browser: load, search, hover navigation, detail panel actions (Open in Browser / Copy Alias / Copy Address) with transient feedback rows.
  • Site grouping in Private list with online indicators and per-group counts.
  • Manual Refresh row + 3-min background polling while connected.
  • Logout while a submenu is open: menu shrinks, next submenu opens at the new row position (AnchorReader position update).
  • Onboarding state: minimal menu (Open Pangolin Setup + Quit) shown while onboardingViewModel.isPresenting is true.
  • Quit terminates cleanly (disconnects tunnel, ~500ms delay, then terminate).

…cOS 26 layout-cycle crash

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.
@DanivosYoun
Copy link
Copy Markdown
Author

Feature Request — Include port / ICMP / site info in /org/{orgId}/user-resources

Summary

Today, only operators with the listSiteResources action can fetch port range, ICMP flag, and site online state for a private resource — these fields are exposed exclusively through GET /org/{orgId}/site-resources. End users without that action (i.e. the typical case for a non-admin connecting through a desktop / mobile client) can list the resources they're allowed to access via GET /org/{orgId}/user-resources, but the response does not include any of:

  • tcpPortRangeString
  • udpPortRangeString
  • disableIcmp
  • siteIds / siteNames / siteOnlines

Current behavior

The macOS Pangolin client renders a "resource detail" view inside its menu bar. Today it issues two calls in parallel:

  1. GET /org/{orgId}/user-resources — used as the source of the list of resources the user is permitted to see.
  2. GET /org/{orgId}/site-resources — used purely to augment that list with port / ICMP / site fields, joined client-side by siteResourceId.

Call #2 is wrapped in a tolerant try? so that a 403 from a non-admin's call does not abort the menu render. As a consequence, a non-admin user gets the resource list correctly but sees a detail panel that omits TCP, UDP, ICMP, and Site rows entirely.

Why we'd like the change

For a non-admin user, the missing fields are not sensitive metadata — they describe how the user is supposed to reach a resource that the server has already decided they may access:

  • TCP / UDP port ranges tell the user which ports work over the tunnel.
  • The ICMP flag tells the user whether ping will respond.
  • Site online status / site names tell the user whether the underlying network is reachable and which site they're routing through. This is especially useful for HA private resources spanning multiple sites.

A user who cannot see this information has a worse troubleshooting experience for resources that the platform has explicitly granted them. The information is also already visible in the dashboard once they have access to the resource page, so withholding it from /user-resources doesn't actually protect anything in practice — it just inconveniences clients that can only call user-scoped endpoints.

Proposed change

Extend the response shape of GET /org/{orgId}/user-resources so each item under siteResources includes the following additional fields (already present on siteResources rows / aggregated like in listAllSiteResourcesByOrg):

{
  // ...existing fields...
  tcpPortRangeString: string,    // e.g. "*", "22,80,8000-8100"
  udpPortRangeString: string,
  disableIcmp: boolean,
  siteIds: number[],
  siteNames: string[],
  siteNiceIds: string[],
  siteOnlines: boolean[],
}

This is identical to the aggregation already produced by listAllSiteResourcesByOrg, so the implementation is largely a copy of that base query into the user-scoped handler (filtered by the user's accessible resource set).

Authorization

No change to the user authorization check is needed — the user is already allowed to see the resource itself; we are only widening the per-resource payload. The endpoint continues to filter to the caller's permitted resources, so a user who can't see a resource still doesn't see it (and therefore doesn't see its ports either).

Why this matters for the macOS client

If /user-resources returns these fields directly, the client can:

  • Drop the secondary /site-resources call entirely for non-admin users (one fewer round trip and one fewer permission check on the menu open).
  • Render a complete detail panel for everyone, not just admins.

Happy to file a follow-up PR on the macOS client (fosrl/apple) to consume the new fields once the server change ships.


Feature Request — Add Resource Groups for client-side categorization

Summary

Add a new first-class Resource Group concept (server-side schema + API + dashboard UI) and include each resource's group name in the resource list responses. Clients (the macOS menu bar today, mobile/web later) will use the group name as the grouping key when displaying long resource lists.

Why not just use the site name?

The macOS client currently groups Private resources in the menu by primary site name (siteNames[0]). That worked for single-site resources, but breaks down once a resource is attached to multiple sites for HA:

  • A site resource with siteIds: [siteA, siteB] has two equally valid "primary" sites — picking the first one is arbitrary and means the same resource can land in different groups depending on the order returned.
  • Two HA resources sharing the same set of sites end up in the same site-named group regardless of how the operator actually wants them organized (e.g. by environment, by team, by service type).
  • Renaming a site to keep the group label tidy has unrelated side effects on the dashboard and on the underlying network.

In short, site identity ≠ logical grouping. Operators want to choose how resources are grouped for end-user clients, independently of the network topology.

Proposed model

Add a resourceGroups table:

resourceGroups {
  resourceGroupId  serial   pk
  orgId            varchar  fk -> orgs.orgId  on delete cascade
  name             varchar  not null
  description      varchar  nullable
  createdAt        bigint   not null   // ms epoch

  unique (orgId, name)   // group names are unique within an org
}

Add a nullable foreign key on the resource side so each resource can belong to at most one group:

siteResources {
  // ...existing columns...
  resourceGroupId  integer  fk -> resourceGroups.resourceGroupId  on delete set null
}

Behavior:

  • Group name uniqueness is enforced at the org level by the (orgId, name) unique constraint — no duplicates by name within the same org.
  • One group per resource (single-select, not multi).
  • Default fallback — if a resource is created or updated without an explicit group selection, the server resolves a per-org "Default" group, creating it on first use.
  • Lazy group creation — there is no need for a separate "create group" endpoint. The existing siteResource create / update payload accepts resourceGroupName (optional string); the server runs find-or-create against (orgId, trimmedName) and writes the resulting resourceGroupId on the resource. Reusing existing endpoints keeps the API surface small.

Dashboard UI

In the Edit Private Resource dialog (the one already showing the Network Settings | Access Policy | SSH Access tabs), add a single-select Resource Group field.

  • The selector should mirror the existing Access Policy → Roles UX (combobox / search + "create new" inline) so operators don't need to learn a new pattern.
  • Difference vs. Roles: the Resource Group selector is single-select, not multi-select.
  • Typing a brand-new name and confirming should create that group on save (server-side find-or-create, see above).
  • Leaving the field empty implicitly means "Default".

API surface — minimal

Add resourceGroupName (optional string) to:

  • PUT /org/{orgId}/site-resource (create) — body field.
  • PATCH /site-resource/{siteResourceId} (update) — body field.
    • undefined → leave the existing assignment untouched.
    • non-empty string → find-or-create the group with that name within the org; assign it.
    • empty string "" or null → reset to the per-org "Default" group.

Extend the resource list responses to include the resolved group:

  • GET /org/{orgId}/user-resources — include resourceGroupName: string on each siteResources item.
  • GET /org/{orgId}/site-resources — same (also include resourceGroupId: number | null for completeness on admin paths).

resourceGroupName is what the clients will key off; surfacing the resolved name (rather than the id) keeps clients from having to do a second lookup.

Why this matters for the macOS client

Once each resource carries a stable resourceGroupName:

  • The menu bar can group Private resources by resourceGroupName instead of by primarySiteName, eliminating the HA misclassification problem.
  • Groups become operator-controlled labels (e.g. "Production API", "Staging", "DBs") instead of accidental side effects of site naming.
  • Future clients (web, mobile) get the same grouping for free.

Happy to file the follow-up PR on fosrl/apple to switch the menu bar grouping over once the server side ships.

Screenshot 2026-05-08 at 1 59 08 AM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant