macOS SwiftUI interface
KumoApp's scene structure, navigation model, toolbar behavior, Settings, status item, App Intents, and the UI constraints that keep daily workflows first.
Scope
KumoApp is the native macOS frontend. It owns windows, menus, Settings, the menu bar status item, App Intents, and SwiftUI state coordination. It does not own Mihomo lifecycle or profile generation; those responsibilities live in KumoCoreKit.
App Scene Structure
The app uses:
WindowGroup(id: "main")for the primary resizable Mac window.Window("About Kumo", id: "about")for the custom About window shared by the App menu and status item menu.Settingsfor preferences reachable through the standard app menu (General / Preferences / Updates tabs).KumoStatusItemControllerfor the persistent menu bar icon and native quick menu. It usesNSStatusItemso Kumo can rebuild menu content dynamically and avoid SwiftUIMenuBarExtravisibility limitations.CommandMenu("Control")for keyboard-accessible Kumo commands.CommandGroup(after: .toolbar)to exposeToggle Sidebar(Cmd+Ctrl+S).CommandMenu("Navigate")for keyboard-accessible jumps to the primary Daily and Inspect destinations without moving navigation state intoKumoCoreKit.@NSApplicationDelegateAdaptor(KumoAppDelegate.self)to bridge AppKit-only behaviour (status item setup, Services menu, Spotlight handoff, Dock badge timer,SMAppService.mainAppsynchronisation,applicationShouldTerminateAfterLastWindowClosed).UNUserNotificationCenterintegration for update notifications: a five-minute runtime release-manifest poll, update-available prompts, install progress stage updates, and actionable buttons routed back toKumoAppStore.
KumoAppContext.shared is a tiny @MainActor singleton that exposes the live KumoAppStore and SwiftUI window-opening actions to the AppDelegate, status item, App Intents, and Services callbacks (none of which sit inside the SwiftUI view tree).
The main window keeps standard macOS chrome, a unified toolbar, and a sensible minimum size.
View Structure
ContentView uses NavigationSplitView with a source-list sidebar grouped into three sections:
- Daily:
OverviewView,ProfilesView,ProxiesView - Inspect:
ConnectionsView,LogsView,RulesView - Configure:
CoreView,SystemProxyView,DNSView,TunView,SnifferView,ResourcesView,OverridesView,SubStoreView,AgentSkillsView
KumoAppStore is an @Observable object that bridges SwiftUI state to KumoCoreKit. Views should call store methods instead of directly constructing controller clients.
The toolbar mode switcher mirrors Sparkle's outbound mode behavior: changing
Rule / Global / Direct persists the controlled mode, patches Mihomo's running
/configs mode, closes existing connections, and refreshes proxy groups. This
uses a dedicated isSwitchingMode state instead of the global isLoading flag
so the Start / Stop toolbar action does not flash disabled during a mode-only
change.
Inspect pages keep toolbar search attached to the page container rather than
only to populated Table / List branches. A no-match state must not remove
the search field, because users need the same toolbar control to clear or edit
the query.
The Connections table shows process icons in the Process column when Mihomo
returns metadata.processPath. Icons are resolved with NSWorkspace from the
enclosing .app / .xpc bundle and cached in the SwiftUI view layer; the
shared ConnectionEntry model carries the process path but KumoCoreKit does
not depend on AppKit.
Overview is a two-pane layout (HSplitView) inspired by single-window utility
apps. The left pane is a searchable proxy node list grouped by
ProxyGroup; each row shows a country flag inferred from the node name plus
a delay pill (green < 300 ms, orange otherwise, red on timeout, — when
unknown). Tapping a row commits via KumoAppStore.selectProxy(group:proxy:),
and a per-group speedometer button triggers testDelay(for:). Typing in the
sidebar search field filters node names and force-expands every matching group
so users do not have to click twice to see hits.
Country flags resolve in three layers, in order:
- Name parsing (
KumoCoreKit.ProxyCountry) — a pure value-type helper that first returns any flag emoji already embedded in the name, then falls back to a two-phase keyword match. Phase 1 scans long keywords (ICU localized region names fromen_US / zh_Hans / zh_Hant / ja_JP, plus a tinymanualAliaseslist for forms ICU does not return such asUSA / UK / 香港 / 狮城). Phase 2 splits the name into ASCII letter tokens and resolves the first one that matches an ISO 3166-1 alpha-2 code, disambiguating ambiguous names likeus-la-02towardUS. - GeoIP fallback (
ProxyNode.detectedCountry) — when name parsing returns nothing, the UI renders the country code asynchronously filled in byKumoCoreKit.ProxyGeoLookup. The store reads the current profile's YAML throughProfileNodeParserto get[name → server], then the lookup actor resolves each server to a country code viaipwho.is(HTTPS, accepts both IPs and hostnames), caching results to~/Library/Application Support/Kumo/proxy-geo-cache.jsonwith a 30-day TTL and a 5-minute failure cooldown. Concurrent requests dedupe and coalesce, so a profile with thousands of nodes sharing 50 unique servers makes 50 HTTP calls, not thousands. globeSF Symbol placeholder — shown only when both layers come up empty. We never invent a country.
ProxyCountry is in KumoCoreKit so CLI or tests can reuse the same
heuristic; unit coverage lives in Tests/KumoCoreTests/ProxyCountryTests.swift,
ProfileNodeParserTests.swift, and ProxyGeoLookupTests.swift and covers
embedded-flag passthrough, ISO / English / CJK keyword hits, ASCII boundary
false positives, Phase 2 token disambiguation, YAML edge cases, and the
lookup actor's cache + cooldown + dedup behaviour.
When the core is stopped, the Overview sidebar does not collapse to an empty
state. It renders a read-only preview of the user's proxy-groups: parsed
offline by ProfileNodeParser.parseProxyGroups(yaml:) into
KumoAppStore.profilePreviewGroups. The preview is refreshed after
refreshProfiles() and loadProxyGroups() so it stays consistent with the
selected profile. The sidebar dims the rows to ~55 % opacity and disables
row tap, context menus, and per-group speedometer actions; flags continue
to resolve from ProxyCountry plus any cached detectedCountry lookups.
Selection state is intentionally not displayed in the preview because
mihomo decides the actual selection at startup (saved selections,
URLTest / Fallback group types) and showing the YAML default would be
misleading. The moment the core transitions to running, the sidebar
swaps in the live proxyGroups from /proxies without rearranging rows
because both sources sort by name.localizedCaseInsensitiveCompare.
The stopped state is no longer surfaced as an in-pane banner — the toolbar
Start / Stop button, menu bar status item, and the cards on the right pane
(zero traffic, profile metadata still visible) already communicate it.
Failures, however, do escalate: KumoAppStore.startCore / stopCore post a
macOS system notification through AppNotificationCoordinator
(postCoreStartFailed(error:) / postCoreStopFailed(error:), category
CORE_STATE) so the user notices even when the main window is occluded.
Successful start / stop is not notified — the UI already reflects it.
The right pane stacks four Liquid Glass cards using kumoGlassCard. Start /
stop and the mode picker are intentionally not duplicated here because the
toolbar already owns them:
- A Profile card with the current profile's name,
sourceDescription, kind capsule (Local/Remote/Inline, plusSub-StorewhenisSubStoreManaged), relative-timeupdatedAt, auto-update interval, and — for remote subscriptions that reportsubscriptionUserInfo— a used / totalProgressViewplus optional expiry date. Bottom row exposes aRefreshbutton callingrefreshProfile(_:)and a deep link to the Profiles destination viaonNavigate(.profiles). - A Traffic card with vertical
↑ Upload/↓ Downloadrows sourced fromKumoAppStore.trafficSnapshot, which is fed by the controller/trafficWebSocket. Stopped reads as0 KB/son both rows — a calm signal that nothing is flowing. The card is collapsible: tapping the header reveals an 80pt-tallCharts.AreaMarksparkline of combined throughput over the last 60 seconds (sparkle-style — monotone curve, vertical accent-color gradient, no axes / legend). The chart reads fromKumoAppStore.trafficHistory, a rolling 60-sample buffer appended in the existingstartTrafficStream()snapshot handler and cleared onstopTrafficStream()/stopCore(). When the buffer is empty (core hasn't run this session) the expanded area shows a "Start Kumo to see traffic history" placeholder so the card doesn't pop. - A System Proxy card that exposes the master
Toggledirectly (setSystemProxyEnabled), shows endpoint, mode, and network service fromstatus.systemProxySettings, and deep-links to the Configure page for staged edits. - A TUN card with the master
Toggle(setTunEnabled), stack / auto-route summary fromstatus.runtimeSettings.tun, the last error fromtunStatuswhen present, and the same deep-link affordance.
Detailed staged-edit forms still live in SystemProxyView and TunView (see
Configure). Overview only exposes the immediate enable toggles so the daily
workflow stays one click away from start, stop, mode switch, system proxy,
and TUN.
The Configure views may begin as small setting surfaces, but user-visible controls must correspond to shared KumoCoreKit behavior. Do not add a SwiftUI-only setting that bypasses the runtime builder, state store, or controller facade.
System-facing configuration forms such as Core runtime settings and System
Proxy settings should stage edits in local SwiftUI state and commit them through
explicit Apply actions. This avoids writing partially typed ports, hosts, or
network service names into the shared controller layer.
The TUN settings page follows the same pattern for stack, routing, MTU, DNS
hijack, DNS resolver, ICMP forwarding, and route-exclude edits. The TUN enable
toggle remains an immediate runtime action, while staged advanced settings are
applied through KumoCoreKit.applyTunSettings; if Mihomo is running, Kumo
restarts the core so the generated runtime YAML and actual TUN interface state
match the form.
SubStoreView is a fully native SwiftUI surface that talks to the bundled
Sub-Store backend over HTTP. When the backend is reachable the view shows a
single thin toolbar with a Subscriptions/Collections segmented picker, an
overflow ⋯ menu (advanced screens, restart, stop, open log), and a backend
connection settings popover. The two primary sections each render their own
list-detail layout. Power-user surfaces (Files, Modules, Artifacts, Archives,
Share Tokens, Server Settings, Backend Logs) are presented on demand as sheets
launched from the overflow menu rather than living in a permanent sidebar, so
the default screen stays focused on subscriptions. Sub-Store data is cached in
a dedicated @Observable SubStoreStore so updates do not invalidate the rest
of the app. There is no embedded web view: management lives entirely in
SwiftUI, and the bundled Node sidecar continues to serve the JSON API. When the
backend is not running (or resources are not yet installed), SubStoreView
falls back to a ContentUnavailableView with the appropriate Start/Prepare
action so users never see an empty management chrome.
AgentSkillsView installs Kumo's bundled kumo-cli Agent Skill into supported
coding-agent skill directories. The target list, supported scopes, destination
paths, install state, and overwrite rules come from
KumoCoreKit.AgentSkillsInstaller, matching kumo skills.
Liquid Glass Usage
Liquid Glass is used sparingly:
- Status cards
- Interactive proxy chips
- Main grouped controls
The implementation provides fallback material backgrounds for older macOS versions. Interactive glass is only used on controls that perform actions.
KumoGlassSurfaceModifier always passes a tint: Color (default .clear) so SwiftUI can interpolate hover / selection tints across state changes without rebuilding the modifier chain.
Sub-Store follows the same rule: backend status and connection-settings cards use glass surfaces, while detail panes and editor sheets stay on plain native materials so high-density list/detail content remains legible.
Settings Surface
SettingsView is a two-tab TabView reserved for app-level preferences, with
About available as a separate window. Runtime status belongs in the main window
and status item menu instead of Settings:
- General —
Open at Login(driven bySMAppService.mainApp),Quit when last window closes(read byapplicationShouldTerminateAfterLastWindowClosed), and a Setup section withRun Setup Againplus aCommand Line Toolrow (install/remove/usr/local/bin/kumo). - Updates — channel picker, optional manifest URL override, and GitHub Releases update checks backed by
AppUpdateManager. - About Kumo window — app icon, version/build, author GitHub link, project links, and the same update-check state used by Settings.
Preferences persist to ~/Library/Application Support/Kumo/preferences.json via UserPreferencesStore. See Persistence and Logging for fields.
First-Run Onboarding
OnboardingView is a four-step sheet attached to KumoRootView and gated by
UserPreferences.hasCompletedOnboarding. It walks the user through optional
helpers without forcing any of them:
- Welcome — short feature summary; users can dismiss with Skip.
- Command Line Tool — calls
KumoController.cliLinkStatus()and offersInstall(orRemove) for the/usr/local/bin/kumosymlink. The install step triggers a macOS administrator authorization prompt viaosascriptbecause the default/usr/local/binrequires elevated privileges. - Agent Skill — lists every
AgentSkillsTarget, all unselected by default, and installs the bundled Kumo skill into each selected agent's~/.<agent>/skillsdirectory throughAgentSkillsInstaller. - Done — summarises what was installed and saves
hasCompletedOnboarding = truethroughKumoAppStore.completeOnboarding().
Settings exposes Run Setup Again (KumoAppStore.reopenOnboarding()) so users
can rerun the flow without resetting the persisted flag. The sheet is also the
preferred path for installing the CLI — direct symlinks created outside Kumo
are still detected, but the GUI step is the documented entry point.
Accessibility
All icon-only controls should have meaningful labels. The toolbar uses Label so VoiceOver and tooltips have clear names. The app should also preserve keyboard access for start, stop, refresh, mode switching, list navigation, filtering, and destructive confirmation.
KumoUIComponents.swift exposes kumoSubtleBackground(in:) and kumoAdaptiveTextWeight(...) helpers that read colorSchemeContrast and legibilityWeight from the environment so custom hairlines / pill backgrounds / non-standard font weights still respond to the user's Increase Contrast and Bold Text preferences.
Design Constraints
- Avoid dense dashboards.
- Keep Inspect and Configure panels secondary to Daily workflow.
- Keep destination titles in the unified toolbar / navigation chrome; do not repeat the same title as a large in-page heading.
- Prefer system fonts, semantic colors, and standard controls.
- Preserve window resizing and standard traffic light buttons.
- Prefer native SwiftUI
Form,List,Table,Menu,Picker,Toggle,PasteButton, andfileImporterbefore custom controls.
System Integration Hooks
These integration points all rely on the .app bundle generated by make app, not on swift run:
- Services menu —
Info.plistNSServicesregisters "Import Profile to Kumo";KumoAppDelegate.importProfileURL(_:userData:error:)consumes the pasteboard string and callsKumoAppStore.importRemoteProfile(...). - Spotlight —
SpotlightIndexerindexes profile summaries on launch and after profile mutations. Tapping a Spotlight result handoff callsKumoAppContext.handleUserActivity(_:)which selects the matching profile. - App Intents —
KumoIntents.swiftexposes Start / Stop / Refresh / SetMode / ToggleSystemProxy intents, surfaced viaKumoShortcutsProvider.KumoModeChoiceis a local mirror ofOutboundModebecause AppIntents metadata extraction cannot see enums declared in another SPM module. - Dock badge — A 1 s timer in the AppDelegate writes connection count into
NSApp.dockTile.badgeLabel. - Notifications —
AppNotificationCoordinatorregisters update categories/actions (Install Now,Remind Me Later,Restart Now) and posts local update notifications fed by the five-minute release-manifest poll documented in App Updates.KumoAppDelegatehandles notification responses and routes actions intoKumoAppStore.handleNotificationAction(...).