@@ -140,30 +140,39 @@ class AppSettings: ObservableObject {
140140// MARK: - Privilege Helper
141141struct 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
0 commit comments