Skip to content

Commit 5a67da6

Browse files
committed
v.1.2.0
1 parent 3d88cdd commit 5a67da6

7 files changed

Lines changed: 217 additions & 28 deletions

File tree

Sources/Models.swift

Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -140,30 +140,39 @@ class AppSettings: ObservableObject {
140140
// MARK: - Privilege Helper
141141
struct PrivilegeHelper {
142142

143-
/// Run a command with admin privileges via AppleScript osascript.
144-
/// Returns the output of the command.
143+
/// Run a shell command with admin privileges via AppleScript's
144+
/// `do shell script ... with administrator privileges`.
145+
/// This triggers the macOS password dialog if needed.
146+
/// Uses NSAppleScript directly to avoid shell-escaping issues.
145147
@discardableResult
146148
static func runAsAdmin(_ command: String) -> String {
147-
// Escape double-quotes and backslashes for AppleScript string
148149
let escaped = command
149150
.replacingOccurrences(of: "\\", with: "\\\\")
150151
.replacingOccurrences(of: "\"", with: "\\\"")
151-
let script = "do shell script \"\(escaped)\" with administrator privileges"
152-
let result = shell("/usr/bin/osascript -e '\(script)' 2>&1")
153-
return result
152+
let source = "do shell script \"\(escaped)\" with administrator privileges"
153+
var errorDict: NSDictionary?
154+
let script = NSAppleScript(source: source)
155+
let result = script?.executeAndReturnError(&errorDict)
156+
if let error = errorDict {
157+
let msg = error[NSAppleScript.errorMessage] as? String ?? "Unknown error"
158+
return "Error: \(msg)"
159+
}
160+
return result?.stringValue ?? ""
154161
}
155162

156-
/// Run a route command with admin privileges (cached per session via helper).
157-
/// First tries direct sudo (works if user has passwordless sudo or already auth'd),
158-
/// then falls back to osascript prompt.
163+
/// Run a single route command with admin privileges.
159164
@discardableResult
160165
static func runRoute(_ command: String) -> String {
161-
// Try direct first (fast path if already elevated)
162-
let direct = shell("sudo \(command) 2>&1")
163-
if direct.contains("Operation not permitted") || direct.contains("Sorry, try again") || direct.contains("Password:") {
164-
return runAsAdmin(command)
165-
}
166-
return direct
166+
return runAsAdmin(command)
167+
}
168+
169+
/// Run multiple commands in a single admin prompt (one password dialog).
170+
/// Commands are joined with " && ".
171+
@discardableResult
172+
static func runBatchAsAdmin(_ commands: [String]) -> String {
173+
guard !commands.isEmpty else { return "" }
174+
let joined = commands.joined(separator: " && ")
175+
return runAsAdmin(joined)
167176
}
168177
}
169178

@@ -293,36 +302,87 @@ class RouteManager: ObservableObject {
293302
func applyAllActiveRules() {
294303
isApplying = true
295304
log("Applying all active rules...")
305+
let gw = AppSettings.shared.effectiveGateway
306+
guard !gw.isEmpty else {
307+
log("⚠️ No gateway set")
308+
isApplying = false
309+
return
310+
}
311+
312+
// Collect all route-add commands for a single admin prompt
313+
var commands: [String] = []
296314
for rule in rules where rule.isEnabled {
297-
applyRoutes(for: rule)
315+
for ip in rule.allIPs {
316+
let cmd = "route -n add \(ip) \(gw) 2>&1"
317+
commands.append(cmd)
318+
logCommand("route -n add \(ip) \(gw)")
319+
}
298320
}
321+
322+
if commands.isEmpty {
323+
log("No active rules with IPs to apply.")
324+
isApplying = false
325+
return
326+
}
327+
328+
// Single password prompt for all commands
329+
let result = PrivilegeHelper.runBatchAsAdmin(commands)
330+
let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines)
331+
if !trimmed.isEmpty {
332+
// Log each line of the batch output
333+
for line in trimmed.components(separatedBy: "\n") {
334+
let l = line.trimmingCharacters(in: .whitespacesAndNewlines)
335+
if !l.isEmpty { log("\(l)") }
336+
}
337+
}
338+
299339
isApplying = false
300340
log("Done applying \(rules.filter { $0.isEnabled }.count) rules.")
301341
}
302342

303343
func removeAllRoutes() {
344+
var commands: [String] = []
304345
for rule in rules {
305-
removeRoutes(for: rule)
346+
for ip in rule.allIPs {
347+
commands.append("route -n delete \(ip) 2>&1 || true")
348+
}
349+
}
350+
if !commands.isEmpty {
351+
PrivilegeHelper.runBatchAsAdmin(commands)
306352
}
307353
log("All routes removed.")
308354
}
309355

310356
private func applyRoutes(for rule: RouteRule) {
311357
let gw = AppSettings.shared.effectiveGateway
312358
guard !gw.isEmpty else { log("⚠️ No gateway set"); return }
359+
360+
var commands: [String] = []
313361
for ip in rule.allIPs {
314-
let cmd = "route -n add \(ip) \(gw)"
315-
logCommand(cmd)
316-
let result = PrivilegeHelper.runRoute(cmd)
317-
log("\(result.trimmingCharacters(in: .whitespacesAndNewlines))")
362+
let cmd = "route -n add \(ip) \(gw) 2>&1"
363+
commands.append(cmd)
364+
logCommand("route -n add \(ip) \(gw)")
365+
}
366+
367+
if !commands.isEmpty {
368+
let result = PrivilegeHelper.runBatchAsAdmin(commands)
369+
let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines)
370+
if !trimmed.isEmpty {
371+
for line in trimmed.components(separatedBy: "\n") {
372+
let l = line.trimmingCharacters(in: .whitespacesAndNewlines)
373+
if !l.isEmpty { log("\(l)") }
374+
}
375+
}
318376
}
319377
}
320378

321379
private func removeRoutes(for rule: RouteRule) {
380+
var commands: [String] = []
322381
for ip in rule.allIPs {
323-
let cmd = "route -n delete \(ip)"
324-
logCommand(cmd)
325-
PrivilegeHelper.runRoute(cmd)
382+
commands.append("route -n delete \(ip) 2>&1 || true")
383+
}
384+
if !commands.isEmpty {
385+
PrivilegeHelper.runBatchAsAdmin(commands)
326386
}
327387
}
328388

Sources/TunnelGuardApp.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,22 @@ struct TunnelGuardApp: App {
1010
var body: some Scene {
1111
WindowGroup {
1212
ContentView()
13-
.frame(minWidth: 800, minHeight: 620)
13+
.frame(width: 900, height: 620)
1414
.onAppear {
1515
NSWindow.allowsAutomaticWindowTabbing = false
16+
// Lock window size and disable zoom/maximize
17+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
18+
if let win = NSApp.windows.first(where: { !($0 is NSPanel) }) {
19+
win.styleMask.remove(.resizable)
20+
win.standardWindowButton(.zoomButton)?.isEnabled = false
21+
win.setContentSize(NSSize(width: 900, height: 620))
22+
win.center()
23+
}
24+
}
1625
}
1726
}
1827
.windowStyle(.hiddenTitleBar)
28+
.defaultSize(width: 900, height: 620)
1929
.commands {
2030
CommandGroup(replacing: .newItem) {}
2131
}
@@ -98,7 +108,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
98108
private func showContextMenu() {
99109
let menu = NSMenu()
100110

101-
let header = NSMenuItem(title: "TunnelGuard v1.0.0", action: nil, keyEquivalent: "")
111+
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
112+
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
113+
let header = NSMenuItem(title: "TunnelGuard v\(version) (\(build))", action: nil, keyEquivalent: "")
102114
header.isEnabled = false
103115
menu.addItem(header)
104116
menu.addItem(.separator())
@@ -132,6 +144,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
132144
@objc func showMainWindow() {
133145
NSApp.activate(ignoringOtherApps: true)
134146
if let win = mainWindow ?? NSApp.windows.first(where: { !($0 is NSPanel) }) {
147+
win.styleMask.remove(.resizable)
148+
win.standardWindowButton(.zoomButton)?.isEnabled = false
135149
win.makeKeyAndOrderFront(nil)
136150
mainWindow = win
137151
}

Sources/Views.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
import SwiftUI
22
import AppKit
33

4+
// MARK: - Bundle Version Helper
5+
struct BundleInfo {
6+
static var version: String {
7+
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
8+
}
9+
static var build: String {
10+
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
11+
}
12+
static var versionLabel: String { "v\(version)" }
13+
static var fullLabel: String { "v\(version) (\(build))" }
14+
}
15+
416
// MARK: - Theme Environment
517
private struct ThemeKey: EnvironmentKey {
618
static let defaultValue: AppSettings.ThemeMode = .dark
@@ -183,7 +195,7 @@ struct SidebarView: View {
183195
Text("TunnelGuard")
184196
.font(.system(size: 14, weight: .bold, design: .rounded))
185197
.foregroundColor(colors.primaryText)
186-
Text("v1.0.0")
198+
Text(BundleInfo.fullLabel)
187199
.font(.system(size: 10))
188200
.foregroundColor(colors.secondaryText)
189201
}
@@ -1104,7 +1116,7 @@ struct AboutView: View {
11041116
Image(systemName: "shield.lefthalf.filled").font(.system(size: 36, weight: .semibold)).foregroundColor(.white)
11051117
}
11061118
Text("TunnelGuard").font(.system(size: 26, weight: .black, design: .rounded)).foregroundColor(colors.primaryText)
1107-
Text("Version 1.0.0 · March 2026").font(.system(size: 12)).foregroundColor(colors.secondaryText)
1119+
Text("Version \(BundleInfo.version) (Build \(BundleInfo.build))").font(.system(size: 12)).foregroundColor(colors.secondaryText)
11081120
Text("macOS VPN Split-Tunnel Manager").font(.system(size: 14)).foregroundColor(colors.secondaryText)
11091121
}
11101122
.padding(.top, 32)
196 KB
Binary file not shown.

TunnelGuard.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
A1000002 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000002 /* Models.swift */; };
1212
A1000003 /* Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000003 /* Views.swift */; };
1313
BBC920172F5A4B5600623AFD /* TunnelGuard.icns in Resources */ = {isa = PBXBuildFile; fileRef = BBC920162F5A4B5600623AFD /* TunnelGuard.icns */; };
14+
BBC9201A2F5A60EC00623AFD /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBC920192F5A60EC00623AFD /* Package.swift */; };
1415
/* End PBXBuildFile section */
1516

1617
/* Begin PBXFileReference section */
@@ -20,6 +21,7 @@
2021
A3000001 /* TunnelGuard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TunnelGuard.app; sourceTree = BUILT_PRODUCTS_DIR; };
2122
BBC920112F5A493300623AFD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
2223
BBC920162F5A4B5600623AFD /* TunnelGuard.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = TunnelGuard.icns; sourceTree = "<group>"; };
24+
BBC920192F5A60EC00623AFD /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = "<group>"; };
2325
/* End PBXFileReference section */
2426

2527
/* Begin PBXFrameworksBuildPhase section */
@@ -45,6 +47,7 @@
4547
A5000002 /* Sources */ = {
4648
isa = PBXGroup;
4749
children = (
50+
BBC920192F5A60EC00623AFD /* Package.swift */,
4851
A2000001 /* TunnelGuardApp.swift */,
4952
A2000002 /* Models.swift */,
5053
A2000003 /* Views.swift */,
@@ -138,6 +141,7 @@
138141
isa = PBXSourcesBuildPhase;
139142
buildActionMask = 2147483647;
140143
files = (
144+
BBC9201A2F5A60EC00623AFD /* Package.swift in Sources */,
141145
A1000001 /* TunnelGuardApp.swift in Sources */,
142146
A1000002 /* Models.swift in Sources */,
143147
A1000003 /* Views.swift in Sources */,

scripts/build.sh

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/bin/bash
2+
# ──────────────────────────────────────────────────────────
3+
# TunnelGuard — Build with auto-versioning
4+
# ──────────────────────────────────────────────────────────
5+
# Usage:
6+
# ./scripts/build.sh # release build
7+
# ./scripts/build.sh debug # debug build
8+
# ──────────────────────────────────────────────────────────
9+
10+
set -euo pipefail
11+
12+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
13+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
14+
cd "$PROJECT_DIR"
15+
16+
CONFIG="${1:-release}"
17+
18+
echo "── Bumping version ──"
19+
"$SCRIPT_DIR/bump-version.sh"
20+
21+
echo ""
22+
echo "── Building ($CONFIG) ──"
23+
swift build -c "$CONFIG"
24+
25+
echo ""
26+
echo "✅ Build complete"

scripts/bump-version.sh

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/bin/bash
2+
# ──────────────────────────────────────────────────────────
3+
# TunnelGuard — Auto Version & Build Number Script
4+
# ──────────────────────────────────────────────────────────
5+
# Usage:
6+
# ./scripts/bump-version.sh # update Info.plist then build
7+
# ./scripts/bump-version.sh --dry # preview without writing
8+
#
9+
# Version: derived from the latest git tag (e.g. v1.2.0 → 1.2.0)
10+
# Build: git commit count on current branch
11+
#
12+
# How to tag a release:
13+
# git tag v1.1.0
14+
# git push --tags
15+
# ──────────────────────────────────────────────────────────
16+
17+
set -euo pipefail
18+
19+
PLIST="Info.plist"
20+
DRY_RUN=false
21+
22+
if [[ "${1:-}" == "--dry" ]]; then
23+
DRY_RUN=true
24+
fi
25+
26+
# Ensure we're in the project root (where Info.plist lives)
27+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
28+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
29+
cd "$PROJECT_DIR"
30+
31+
if [[ ! -f "$PLIST" ]]; then
32+
echo "Error: $PLIST not found in $PROJECT_DIR"
33+
exit 1
34+
fi
35+
36+
# ── Build number: total git commit count ──
37+
if git rev-parse --git-dir > /dev/null 2>&1; then
38+
BUILD=$(git rev-list --count HEAD 2>/dev/null || echo "1")
39+
else
40+
echo "Warning: not a git repo, using build number 1"
41+
BUILD="1"
42+
fi
43+
44+
# ── Version: latest git tag (strip leading 'v'), fallback to current plist value ──
45+
if git rev-parse --git-dir > /dev/null 2>&1; then
46+
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
47+
if [[ -n "$TAG" ]]; then
48+
VERSION="${TAG#v}"
49+
else
50+
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$PLIST" 2>/dev/null || echo "1.0.0")
51+
echo "Warning: no git tags found, keeping current version $VERSION"
52+
fi
53+
else
54+
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$PLIST" 2>/dev/null || echo "1.0.0")
55+
fi
56+
57+
# ── Apply ──
58+
echo "╔══════════════════════════════════════╗"
59+
echo "║ TunnelGuard Version Bump ║"
60+
echo "╠══════════════════════════════════════╣"
61+
echo "║ Version : $VERSION"
62+
echo "║ Build : $BUILD"
63+
echo "╚══════════════════════════════════════╝"
64+
65+
if [[ "$DRY_RUN" == true ]]; then
66+
echo "(dry run — no files modified)"
67+
exit 0
68+
fi
69+
70+
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST"
71+
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD" "$PLIST"
72+
73+
echo "✅ Info.plist updated"

0 commit comments

Comments
 (0)