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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions StaticRouteHelper.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
AA000004000000000000BB01 /* cn.magicdian.staticrouter.service.plist in CopyFiles */ = {isa = PBXBuildFile; fileRef = AA000004000000000000BB02 /* cn.magicdian.staticrouter.service.plist */; };
AA000001000000000000BB02 /* InstallMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000000000000BB02 /* InstallMethod.swift */; };
AA000001000000000000BB03 /* PrivilegedHelperManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000000000000BB03 /* PrivilegedHelperManager.swift */; };
AA900001000000000000CC01 /* SettingsNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA900002000000000000CC01 /* SettingsNavigator.swift */; };
/* End PBXBuildFile section */

/* Begin PBXShellScriptBuildPhase section */
Expand Down Expand Up @@ -177,6 +178,7 @@
AA000002000000000000AA33 /* StaticRouteLegacy.xcdatamodeld */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodeld; path = StaticRouteLegacy.xcdatamodeld; sourceTree = "<group>"; };
AA000002000000000000BB02 /* InstallMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallMethod.swift; sourceTree = "<group>"; };
AA000002000000000000BB03 /* PrivilegedHelperManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivilegedHelperManager.swift; sourceTree = "<group>"; };
AA900002000000000000CC01 /* SettingsNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigator.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -376,6 +378,7 @@
AA000002000000000000AA21 /* SystemRouteReader.swift */,
AA000002000000000000BB02 /* InstallMethod.swift */,
AA000002000000000000BB03 /* PrivilegedHelperManager.swift */,
AA900002000000000000CC01 /* SettingsNavigator.swift */,
);
path = Services;
sourceTree = "<group>";
Expand Down Expand Up @@ -542,6 +545,7 @@
3962561B296EE01B00DB6000 /* RouterCommand.swift in Sources */,
AA000001000000000000BB02 /* InstallMethod.swift in Sources */,
AA000001000000000000BB03 /* PrivilegedHelperManager.swift in Sources */,
AA900001000000000000CC01 /* SettingsNavigator.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -567,7 +571,7 @@
CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 66;
CURRENT_PROJECT_VERSION = 68;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
Expand Down Expand Up @@ -598,7 +602,7 @@
CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 66;
CURRENT_PROJECT_VERSION = 68;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
Expand Down Expand Up @@ -760,9 +764,9 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
CURRENT_PROJECT_VERSION = 66;
CURRENT_PROJECT_VERSION = 68;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 2.2.1;
MARKETING_VERSION = 2.2.2;
PRODUCT_BUNDLE_IDENTIFIER = cn.magicdian.staticrouter;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ROUTER;
Expand Down Expand Up @@ -790,9 +794,9 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
CURRENT_PROJECT_VERSION = 66;
CURRENT_PROJECT_VERSION = 68;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 2.2.1;
MARKETING_VERSION = 2.2.2;
PRODUCT_BUNDLE_IDENTIFIER = cn.magicdian.staticrouter;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ROUTER;
Expand Down
61 changes: 61 additions & 0 deletions StaticRouter/Services/SettingsNavigator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//
// SettingsNavigator.swift
// StaticRouteHelper
//
// Legacy settings navigation helper (macOS 12-13).
//

import AppKit
import Foundation
import os
import SwiftUI

enum SettingsNavigator {
private static let logger = Logger(subsystem: "cn.magicdian.staticrouter", category: "settings-navigation")

/// Opens the app settings window via selector fallbacks for macOS 12-13.
/// Returns true when an action was successfully dispatched.
@MainActor
@discardableResult
static func openAppSettings() -> Bool {
let settingsWindowSelector = Selector(("showSettingsWindow:"))
let preferencesWindowSelector = Selector(("showPreferencesWindow:"))

if NSApp.sendAction(settingsWindowSelector, to: nil, from: nil) {
logger.info("Opened settings via showSettingsWindow: fallback")
NSApp.activate(ignoringOtherApps: true)
return true
}

if NSApp.sendAction(preferencesWindowSelector, to: nil, from: nil) {
logger.info("Opened settings via showPreferencesWindow: fallback")
NSApp.activate(ignoringOtherApps: true)
return true
}

logger.error("Failed to open settings window using all known legacy actions")
NSApp.activate(ignoringOtherApps: true)
return false
}

/// Unified settings entry for SwiftUI views.
/// - macOS 14+: uses SettingsLink to open Settings scene.
/// - macOS 12-13: falls back to selector-based window opening.
struct Entry<Label: View>: View {
@ViewBuilder let label: () -> Label

var body: some View {
if #available(macOS 14, *) {
SettingsLink {
label()
}
} else {
Button {
SettingsNavigator.openAppSettings()
} label: {
label()
}
}
}
}
}
4 changes: 2 additions & 2 deletions StaticRouter/View/Customized/StatusBanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ enum BannerStyle {
// MARK: - StatusBanner

/// Generic banner with a style-driven icon/background and an arbitrary action button.
/// Use the `@ViewBuilder actionButton` parameter to supply any button type (plain Button
/// or SettingsLink) so callers control the navigation target.
/// Use the `@ViewBuilder actionButton` parameter to supply any button type so callers
/// control the navigation target.
struct StatusBanner<ActionButton: View>: View {
let style: BannerStyle
let message: LocalizedStringKey
Expand Down
76 changes: 56 additions & 20 deletions StaticRouter/View/LegacyRouteListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -305,78 +305,114 @@ struct LegacyRouteEditSheet: View {
}

var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(isEditing ? String(localized: "route.edit.title.edit") : String(localized: "route.edit.title.add"))
.font(.headline)
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text(isEditing ? String(localized: "route.edit.title.edit") : String(localized: "route.edit.title.add"))
.font(.title2.bold())
Text("Network / Gateway")
.font(.footnote)
.foregroundStyle(.secondary)
}

// Network + Prefix
VStack(alignment: .leading, spacing: 6) {
Text(String(localized: "route.edit.field.destination.label"))
.font(.subheadline).foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 10) {
Label(String(localized: "route.edit.field.destination.label"), systemImage: "dot.scope")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
HStack(spacing: 8) {
TextField("192.168.4.0", text: $network)
.textFieldStyle(.roundedBorder)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(networkError != nil ? Color.red : Color.clear, lineWidth: 1)
.stroke(networkError != nil ? RouterTheme.danger : Color.clear, lineWidth: 1)
)
.onChange(of: network) { _ in validateNetwork() }

Text("/").foregroundStyle(.secondary)

TextField("24", value: $prefixLength, format: .number)
.multilineTextAlignment(.center)
.textFieldStyle(.roundedBorder)
.frame(width: 50)
.frame(width: 68)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(prefixLengthError != nil ? Color.red : Color.clear, lineWidth: 1)
.stroke(prefixLengthError != nil ? RouterTheme.danger : Color.clear, lineWidth: 1)
)
}

Text("= \(subnetMaskPreview)")
.font(.caption)
.foregroundStyle(.secondary)

if let err = networkError {
Text(err).font(.caption).foregroundStyle(.red)
Text(err).font(.caption).foregroundStyle(RouterTheme.danger)
}
if let err = prefixLengthError {
Text(err).font(.caption).foregroundStyle(RouterTheme.danger)
}
}
.padding(14)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(RouterTheme.subtleBorder, lineWidth: 0.6)
)

// Gateway Type + Gateway
VStack(alignment: .leading, spacing: 6) {
Text(String(localized: "route.edit.field.gateway.label"))
.font(.subheadline).foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 10) {
Label(String(localized: "route.edit.field.gateway.label"), systemImage: "arrow.triangle.turn.up.right.diamond")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Picker(String(localized: "route.edit.field.gateway.label"), selection: $gatewayTypeStr) {
Text(String(localized: "route.edit.gateway.ip_option")).tag("ipAddress")
Text(String(localized: "route.edit.gateway.interface_option")).tag("interface")
}
.pickerStyle(.radioGroup)
.pickerStyle(.segmented)
.onChange(of: gatewayTypeStr) { _ in gateway = ""; validateGateway() }

TextField(gatewayTypeStr == "ipAddress" ? "10.0.0.1" : "utun3", text: $gateway)
.textFieldStyle(.roundedBorder)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(gatewayError != nil ? Color.red : Color.clear, lineWidth: 1)
.stroke(gatewayError != nil ? RouterTheme.danger : Color.clear, lineWidth: 1)
)
.onChange(of: gateway) { _ in validateGateway() }

if let err = gatewayError {
Text(err).font(.caption).foregroundStyle(.red)
Text(err).font(.caption).foregroundStyle(RouterTheme.danger)
}
}

Divider()
.padding(14)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(RouterTheme.subtleBorder, lineWidth: 0.6)
)

HStack {
Spacer()
Button(String(localized: "route.edit.button.cancel")) { dismiss() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.bordered)
Button(isEditing ? String(localized: "route.edit.button.save") : String(localized: "route.edit.button.add")) { save() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(!isFormValid)
}
.padding(.top, 4)
.padding(.horizontal, 2)
}
.padding(24)
.frame(width: 360)
.padding(20)
.frame(width: 460)
.onAppear { populate() }
}

private var subnetMaskPreview: String {
guard prefixLength >= 0, prefixLength <= 32 else { return String(localized: "route.edit.mask_preview.invalid") }
let mask: UInt32 = prefixLength == 0 ? 0 : (~UInt32(0) << (32 - prefixLength))
return "\((mask>>24)&0xFF).\((mask>>16)&0xFF).\((mask>>8)&0xFF).\(mask&0xFF)"
}

private func populate() {
guard let mo = existingMO else { return }
network = mo.network
Expand Down
14 changes: 6 additions & 8 deletions StaticRouter/View/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,21 +88,21 @@ struct MainWindow14: View {
if routerService.helperStatus == .pendingActivation {
// Priority 1: installed but background switch is off — needs user approval
StatusBanner(style: .warning, message: "helper.banner.pending_approval.message") {
SettingsLink {
SettingsNavigator.Entry {
Text(String(localized: "helper.banner.goto_settings"))
}
}
} else if routerService.helperStatus != .installed {
// Priority 2: helper not installed / needs upgrade / not compatible
StatusBanner(style: .warning, message: "helper.banner.message") {
SettingsLink {
SettingsNavigator.Entry {
Text(String(localized: "helper.banner.goto_settings"))
}
}
} else if routerService.helperManager.activeMethod == .smJobBless {
// Priority 3 (macOS 14+ only): installed via bless, modern method available
StatusBanner(style: .info, message: "helper.banner.bless_upgrade.message") {
SettingsLink {
SettingsNavigator.Entry {
Text(String(localized: "helper.banner.goto_settings"))
}
}
Expand Down Expand Up @@ -142,8 +142,8 @@ struct LegacyMainWindow: View {
// macOS 12–13: only show warning banner (no SMAppService upgrade suggestion)
if routerService.helperStatus != .installed {
StatusBanner(style: .warning, message: "helper.banner.message") {
Button(String(localized: "helper.banner.goto_settings")) {
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
SettingsNavigator.Entry {
Text(String(localized: "helper.banner.goto_settings"))
}
}
}
Expand Down Expand Up @@ -176,9 +176,7 @@ struct LegacySidebarView: View {
.safeAreaInset(edge: .bottom) {
HStack {
Spacer()
Button {
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
} label: {
SettingsNavigator.Entry {
Image(systemName: "gearshape")
.font(.system(size: 13, weight: .semibold))
.frame(width: 26, height: 22)
Expand Down
14 changes: 7 additions & 7 deletions StaticRouter/View/RouteEditSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,18 @@ struct RouteEditSheet: View {
// MARK: - Body

var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text(isEditing ? String(localized: "route.edit.title.edit") : String(localized: "route.edit.title.add"))
.font(.title2.bold())
Text("CIDR / Gateway / Group")
Text("Network / Gateway / Group")
.font(.footnote)
.foregroundStyle(.secondary)
}

// Network Address + Prefix Length
VStack(alignment: .leading, spacing: 10) {
Text(String(localized: "route.edit.field.destination.label"))
Label(String(localized: "route.edit.field.destination.label"), systemImage: "dot.scope")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Expand Down Expand Up @@ -103,7 +103,7 @@ struct RouteEditSheet: View {

// Gateway Type Picker + Gateway Input
VStack(alignment: .leading, spacing: 10) {
Text(String(localized: "route.edit.field.gateway.label"))
Label(String(localized: "route.edit.field.gateway.label"), systemImage: "arrow.triangle.turn.up.right.diamond")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)

Expand Down Expand Up @@ -136,7 +136,7 @@ struct RouteEditSheet: View {
// Group Multi-Select
if !allGroups.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text(String(localized: "route.edit.field.groups.label"))
Label(String(localized: "route.edit.field.groups.label"), systemImage: "folder.badge.plus")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 130), spacing: 8)], spacing: 8) {
Expand Down Expand Up @@ -175,8 +175,6 @@ struct RouteEditSheet: View {
)
}

Divider()

HStack {
Spacer()
Button(String(localized: "route.edit.button.cancel")) { dismiss() }
Expand All @@ -187,6 +185,8 @@ struct RouteEditSheet: View {
.buttonStyle(.borderedProminent)
.disabled(!isFormValid)
}
.padding(.top, 4)
.padding(.horizontal, 2)
}
.padding(20)
.frame(width: 460)
Expand Down
2 changes: 1 addition & 1 deletion StaticRouter/View/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ struct SidebarView: View {

Spacer()

SettingsLink {
SettingsNavigator.Entry {
Image(systemName: "gearshape")
.font(.system(size: 13, weight: .semibold))
.frame(width: 26, height: 22)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-24
Loading
Loading