Skip to content

Commit 5a93504

Browse files
committed
Fix macOS client UX and IPAM gateway reservation
- Fix tunnel helper not launching: AppleScript do shell script blocks on child fds; use disown + stdin close to fully detach helper process - Fix tunnel status resetting on tab switch: kill(pid,0) returns EPERM for root-owned helper, now correctly treated as alive - Fix passkey delete not updating UI: make PasskeyManager ObservableObject with @published hasStoredCredential flag - Fix error messages persisting after passkey removal: clear both local statusMessage and appState.errorMessage on delete - Fix app window not gaining focus: activate process and makeKeyAndOrderFront on launch for bare binary / .app bundle - Reserve 10.64.0.1 for server gateway: IPAM tunnel cursor starts at offset 2, genesis server always gets .1, clients get .2+
1 parent fc23ed8 commit 5a93504

File tree

7 files changed

+79
-42
lines changed

7 files changed

+79
-42
lines changed

apps/LemonadeNexusMac/Sources/LemonadeNexusMac/LemonadeNexusApp.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ struct LemonadeNexusApp: App {
8989
private func configureAppearance() {
9090
// Set the accent color globally
9191
NSApplication.shared.appearance = NSAppearance(named: .aqua)
92+
93+
// When launched as a bare binary (not .app bundle), macOS may not
94+
// activate the process or make the window key. Force both.
95+
NSApplication.shared.activate(ignoringOtherApps: true)
96+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
97+
NSApplication.shared.windows.first?.makeKeyAndOrderFront(nil)
98+
}
9299
}
93100

94101
private func attemptAutoConnect() {

apps/LemonadeNexusMac/Sources/LemonadeNexusMac/Services/PasskeyManager.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,17 @@ struct PasskeyCredentialInfo: Codable {
3535
var signCount: UInt32 = 0
3636
}
3737

38-
final class PasskeyManager {
38+
final class PasskeyManager: ObservableObject {
3939
static let shared = PasskeyManager()
4040

41+
@Published var hasStoredCredential: Bool = false
42+
4143
private let service = "io.lemonade-nexus.passkey"
4244
private let credentialAccount = "passkey-credential"
4345

44-
private init() {}
46+
private init() {
47+
hasStoredCredential = loadCredentialInfo() != nil
48+
}
4549

4650
// MARK: - Registration
4751

@@ -204,6 +208,7 @@ final class PasskeyManager {
204208
kSecAttrAccount as String: credentialAccount,
205209
]
206210
SecItemDelete(infoQuery as CFDictionary)
211+
hasStoredCredential = false
207212
}
208213

209214
// MARK: - Keychain Helpers
@@ -250,6 +255,7 @@ final class PasskeyManager {
250255
private func saveCredentialInfo(_ info: PasskeyCredentialInfo) throws {
251256
let data = try JSONEncoder().encode(info)
252257
try saveToKeychain(data: data, tag: credentialAccount)
258+
hasStoredCredential = true
253259
}
254260

255261
private func loadCredentialInfo() -> PasskeyCredentialInfo? {

apps/LemonadeNexusMac/Sources/LemonadeNexusMac/Services/TunnelManager.swift

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,24 @@ final class TunnelManager: ObservableObject {
5151
// Kill any existing tunnel helper first
5252
killExistingHelper()
5353

54-
// Run the helper with admin privileges via AppleScript.
55-
// The helper creates the utun device, configures IP/routes, and runs
56-
// BoringTun packet forwarding. It stays alive until signalled.
57-
let escaped = helperPath
58-
.replacingOccurrences(of: "\\", with: "\\\\")
59-
.replacingOccurrences(of: "\"", with: "\\\"")
60-
let escapedConfig = configPath
61-
.replacingOccurrences(of: "\\", with: "\\\\")
62-
.replacingOccurrences(of: "\"", with: "\\\"")
63-
64-
// Launch the helper in the background (& disown) so AppleScript returns
65-
// immediately while the tunnel keeps running.
66-
let cmd = "\(escaped) \(escapedConfig) &"
67-
let script = "do shell script \"\(cmd)\" with administrator privileges"
54+
// AppleScript's `do shell script` waits for ALL child file descriptors
55+
// to close, not just the main process. We must fully detach the helper:
56+
// - close stdin (<&-)
57+
// - redirect stdout/stderr away from the pipe
58+
// - disown to remove from job table
59+
let logPath = "/tmp/lnsdk_tunnel.log"
60+
let launcherPath = "/tmp/lnsdk_launcher.sh"
61+
let launcherContent = """
62+
#!/bin/bash
63+
"\(helperPath)" "\(configPath)" </dev/null >>/dev/null 2>"\(logPath)" &
64+
disown $!
65+
exit 0
66+
"""
67+
try launcherContent.write(toFile: launcherPath, atomically: true, encoding: .utf8)
68+
try FileManager.default.setAttributes(
69+
[.posixPermissions: 0o755], ofItemAtPath: launcherPath)
70+
71+
let script = "do shell script \"/bin/bash \(launcherPath)\" with administrator privileges"
6872

6973
var errorDict: NSDictionary?
7074
guard let appleScript = NSAppleScript(source: script) else {
@@ -107,14 +111,22 @@ final class TunnelManager: ObservableObject {
107111
}
108112

109113
/// Check if the tunnel helper is still running.
114+
/// The helper runs as root, so kill(pid, 0) returns EPERM (errno 1) when
115+
/// the process exists but we lack permission — that still means it's alive.
110116
func refreshStatus() {
111117
guard let pidStr = try? String(contentsOfFile: pidPath, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines),
112118
let pid = Int32(pidStr) else {
113-
isTunnelActive = false
119+
// No PID file — only mark inactive if we didn't just start the tunnel
120+
if !isTransitioning {
121+
isTunnelActive = false
122+
}
114123
return
115124
}
116-
// kill(pid, 0) checks if the process exists without actually sending a signal
117-
isTunnelActive = (kill(pid, 0) == 0)
125+
let result = kill(pid, 0)
126+
// result == 0: we own the process (alive)
127+
// result == -1, errno == EPERM: process exists but owned by root (alive)
128+
// result == -1, errno == ESRCH: no such process (dead)
129+
isTunnelActive = (result == 0 || (result == -1 && errno == EPERM))
118130
}
119131

120132
// MARK: - Private
@@ -123,18 +135,22 @@ final class TunnelManager: ObservableObject {
123135
guard let pidStr = try? String(contentsOfFile: pidPath, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines),
124136
let pid = Int32(pidStr) else { return }
125137

126-
// Send SIGTERM to gracefully shut down the helper (it tears down the tunnel)
127-
kill(pid, SIGTERM)
128-
129-
// Wait briefly for it to exit
130-
for _ in 0..<10 {
131-
if kill(pid, 0) != 0 { break }
132-
Thread.sleep(forTimeInterval: 0.2)
133-
}
134-
135-
// Force kill if still alive
136-
if kill(pid, 0) == 0 {
137-
kill(pid, SIGKILL)
138+
// Try direct kill first (works if we own the process)
139+
if kill(pid, SIGTERM) == 0 {
140+
for _ in 0..<10 {
141+
if kill(pid, 0) != 0 { break }
142+
Thread.sleep(forTimeInterval: 0.2)
143+
}
144+
if kill(pid, 0) == 0 {
145+
kill(pid, SIGKILL)
146+
}
147+
} else if errno == EPERM {
148+
// Helper runs as root — need privilege escalation to kill it
149+
let script = "do shell script \"kill \(pid) 2>/dev/null; sleep 1; kill -9 \(pid) 2>/dev/null; rm -f \(pidPath)\" with administrator privileges"
150+
if let as_ = NSAppleScript(source: script) {
151+
var err: NSDictionary?
152+
as_.executeAndReturnError(&err)
153+
}
138154
}
139155

140156
try? FileManager.default.removeItem(atPath: pidPath)

apps/LemonadeNexusMac/Sources/LemonadeNexusMac/Views/LoginView.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import AuthenticationServices
33

44
struct LoginView: View {
55
@EnvironmentObject private var appState: AppState
6+
@ObservedObject private var passkeyManager = PasskeyManager.shared
67
@State private var serverURL: String = ""
78
@State private var username: String = ""
89
@State private var password: String = ""
@@ -350,9 +351,9 @@ struct LoginView: View {
350351
.foregroundColor(.lemonYellow)
351352
.padding(.top, 8)
352353

353-
if PasskeyManager.shared.hasCredential {
354+
if passkeyManager.hasStoredCredential {
354355
// Existing passkey — show sign-in
355-
if let storedUser = PasskeyManager.shared.storedUserId {
356+
if let storedUser = passkeyManager.storedUserId {
356357
Text("Sign in as **\(storedUser)** using Touch ID.")
357358
.font(.subheadline)
358359
.foregroundColor(.textSecondary)
@@ -368,7 +369,12 @@ struct LoginView: View {
368369
.buttonStyle(LemonButtonStyle())
369370
.disabled(appState.isLoading)
370371

371-
Button(action: { PasskeyManager.shared.deleteCredential() }) {
372+
Button(action: {
373+
passkeyManager.deleteCredential()
374+
statusMessage = nil
375+
isError = false
376+
appState.errorMessage = nil
377+
}) {
372378
Text("Remove stored passkey")
373379
.font(.caption2)
374380
}

projects/LemonadeNexus/include/LemonadeNexus/IPAM/IPAMService.hpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ class IPAMService : public core::IService<IPAMService>,
7575
std::unordered_map<std::string, AllocationSet> allocations_;
7676

7777
/// Cursor trackers for sequential allocation within each block.
78-
uint32_t next_tunnel_ip_{0}; // next offset within 10.64.0.0/10
78+
/// Tunnel starts at offset 2: .0 = network, .1 = server gateway.
79+
uint32_t next_tunnel_ip_{2}; // next offset within 10.64.0.0/10
7980
uint32_t next_private_ip_{0}; // next offset within 10.128.0.0/9
8081
uint32_t next_shared_ip_{0}; // next offset within 172.20.0.0/14
8182
};

projects/LemonadeNexus/src/Core/ServerIdentity.cpp

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,12 +151,12 @@ std::string resolve_tunnel_ip(
151151
tunnel_bind_ip = alloc.base_network;
152152
}
153153
} else {
154-
// Genesis server (no peers): self-allocate the first tunnel IP
155-
auto alloc = ipam.allocate_tunnel_ip(server_node_id);
156-
if (!alloc.base_network.empty()) {
157-
tunnel_bind_ip = alloc.base_network;
158-
spdlog::info("Genesis server -- allocated tunnel IP: {}", tunnel_bind_ip);
159-
}
154+
// Genesis/root server: always use .1 as the gateway address.
155+
// Record an allocation so IPAM tracks this node, but override to .1.
156+
auto server_alloc = ipam.allocate_tunnel_ip(server_node_id);
157+
(void)server_alloc;
158+
tunnel_bind_ip = "10.64.0.1";
159+
spdlog::info("Genesis server -- gateway tunnel IP: {}", tunnel_bind_ip);
160160
}
161161

162162
// Strip CIDR suffix (e.g. "10.64.0.1/32" -> "10.64.0.1")

projects/LemonadeNexus/src/IPAM/IPAMService.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,8 @@ void IPAMService::load_allocations() {
337337
if (aset.tunnel) {
338338
auto [ip, prefix] = parse_cidr(aset.tunnel->base_network);
339339
uint32_t offset = ip - kTunnelBase + 1;
340-
if (offset > next_tunnel_ip_) next_tunnel_ip_ = offset;
340+
// Never go below 2 — .0 is network, .1 is server gateway
341+
if (offset > next_tunnel_ip_) next_tunnel_ip_ = std::max(offset, uint32_t{2});
341342
}
342343
if (aset.private_subnet) {
343344
auto [ip, prefix] = parse_cidr(aset.private_subnet->base_network);

0 commit comments

Comments
 (0)