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
19 changes: 17 additions & 2 deletions Glint/Chrome/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1147,11 +1147,20 @@ private struct TerminalPane: View {
ForEach(store.webRemoteInterfaceOptions) { iface in
Text(verbatim: "\(iface.name) (\(iface.address))").tag(iface.name)
}
if store.webRemoteListenInterface != WebRemoteListenTarget.loopback,
store.webRemoteListenInterface != WebRemoteListenTarget.any,
!store.webRemoteInterfaceOptions.contains(where: { $0.name == store.webRemoteListenInterface }) {
Text(verbatim: store.webRemoteListenInterface)
.tag(store.webRemoteListenInterface)
}
Text("All interfaces (less secure)").tag(WebRemoteListenTarget.any)
}
.pickerStyle(.menu)
.labelsHidden()
Button(action: { store.refreshWebRemoteInterfaces() }) {
Button(action: {
store.refreshWebRemoteInterfaces()
WebRemoteServer.shared.refreshListenAddress()
}) {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
Expand Down Expand Up @@ -1238,6 +1247,7 @@ private struct TerminalPane: View {
Text("Connected browsers will be disconnected. A new key and ports will be generated, so old links will stop working.")
}
.onAppear {
store.refreshWebRemoteInterfaces()
presentWebRemoteAlert(for: store.webRemoteStatus)
}
.onChange(of: store.webRemoteStatus) { _, status in
Expand Down Expand Up @@ -1286,6 +1296,11 @@ private struct TerminalPane: View {
return String(localized: "Off — no network ports are bound.")
case .starting:
return String(localized: "Starting the local web server…")
case let .waitingForInterface(name):
return String(
format: String(localized: "Waiting for %@ to become available. Reconnects automatically."),
name
)
case .ready:
return String(localized: "Ready — copy a session link to another browser.")
case let .portConflict(port):
Expand Down Expand Up @@ -1336,7 +1351,7 @@ private struct TerminalPane: View {
if let iface = store.webRemoteInterfaceOptions.first(where: { $0.name == store.webRemoteListenInterface }) {
return iface.address
}
return String(localized: "Selected interface is no longer available. Pick another or use All interfaces.")
return String(localized: "Selected interface is temporarily unavailable. Reconnects automatically when it returns, or choose another interface.")
}
}

Expand Down
15 changes: 13 additions & 2 deletions Glint/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -4457,13 +4457,24 @@
}
}
},
"Selected interface is no longer available. Pick another or use All interfaces.": {
"Waiting for %@ to become available. Reconnects automatically.": {
"extractionState": "manual",
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "所选网卡已不可用。请另选一个或使用“全部网卡”。"
"value": "正在等待 %@ 恢复可用,恢复后会自动重连。"
}
}
}
},
"Selected interface is temporarily unavailable. Reconnects automatically when it returns, or choose another interface.": {
"extractionState": "manual",
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "所选网卡暂时不可用,恢复后会自动重连,也可选择其他网卡。"
}
}
}
Expand Down
142 changes: 98 additions & 44 deletions Glint/WebRemote/WebRemoteServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ private let webRemoteLogger = Logger(subsystem: "app.glint", category: "WebRemot
enum WebRemoteStatus: Equatable {
case stopped
case starting
case waitingForInterface(name: String)
case ready(urls: [String])
case portConflict(port: UInt16)
case failed(message: String)
Expand Down Expand Up @@ -237,17 +238,30 @@ final class WebRemoteServer: @unchecked Sendable {
/// Raw 32-byte key material decoded from `token`; empty until the server
/// starts with a valid token. Drives the challenge-response handshake.
private var tokenKey = Data()
private var ports = WebRemotePortStore.loadOrCreate()
private let defaults: UserDefaults
private let resolveBindAddress: (String) -> String?
private let interfaceRetryInterval: TimeInterval
private var ports: WebRemotePorts
private var listenInterface = WebRemoteListenTarget.loopback
private var lastBoundAddress: String?
private var pathMonitor: NWPathMonitor?
private var pathRestartScheduled = false
private var interfaceRetryTimer: DispatchSourceTimer?
private var terminalSizeRevision: UInt64 = 0
private var assetCache: [String: Data] = [:]
private var statusHandler: ((WebRemoteStatus) -> Void)?
private var secretStorage: WebRemoteSecretStorage = WebRemoteKeychainStorage.shared

private init() {}
init(
defaults: UserDefaults = .standard,
interfaceRetryInterval: TimeInterval = 5,
resolveBindAddress: @escaping (String) -> String? = WebRemoteListenTarget.bindAddress
) {
self.defaults = defaults
self.interfaceRetryInterval = interfaceRetryInterval
self.resolveBindAddress = resolveBindAddress
ports = WebRemotePortStore.loadOrCreate(defaults: defaults)
}

/// Swap the access-token backing store. Staged like `setListenInterface`:
/// it takes effect on the next `start()`.
Expand Down Expand Up @@ -297,6 +311,13 @@ final class WebRemoteServer: @unchecked Sendable {
}
}

func refreshListenAddress() {
queue.async { [weak self] in
guard let self, let runID = self.runID else { return }
self.handlePathUpdate(runID: runID)
}
}

func refreshAppearance() {
queue.async { [weak self] in
guard let self else { return }
Expand Down Expand Up @@ -380,19 +401,17 @@ final class WebRemoteServer: @unchecked Sendable {
emit(.starting)
token = WebRemoteAccessKeyStore.loadOrCreate(storage: secretStorage)
tokenKey = WebRemoteCrypto.tokenKey(from: token) ?? Data()
ports = WebRemotePortStore.loadOrCreate()
ports = WebRemotePortStore.loadOrCreate(defaults: defaults)
let currentRun = UUID()
runID = currentRun

do {
let isExplicitWildcard = listenInterface == WebRemoteListenTarget.any
let bindAddress = WebRemoteListenTarget.bindAddress(for: listenInterface)
// A user-chosen NIC that currently has no IPv4 must fail loudly.
// bindAddress returns nil both for "explicit All interfaces" and
// for "selected NIC vanished"; conflating them would silently widen
// exposure to 0.0.0.0 — the opposite of picking a NIC.
let bindAddress = resolveBindAddress(listenInterface)
// A selected NIC may temporarily lose IPv4 during DHCP or wake.
// Keep waiting for that NIC; nil must never become a wildcard bind.
guard isExplicitWildcard || bindAddress != nil else {
failLocked("Selected interface is no longer available.")
waitForInterfaceLocked()
return
}
lastBoundAddress = bindAddress
Expand Down Expand Up @@ -439,22 +458,66 @@ final class WebRemoteServer: @unchecked Sendable {
webSocket.start(queue: queue)
startPathMonitorLocked(currentRun)
} catch {
failLocked(error.localizedDescription)
if shouldWaitForInterface(after: error) {
waitForInterfaceLocked()
} else {
failLocked(error.localizedDescription)
}
}
}

private var usesNamedInterface: Bool {
listenInterface != WebRemoteListenTarget.loopback
&& listenInterface != WebRemoteListenTarget.any
}

private func shouldWaitForInterface(after error: Error) -> Bool {
guard usesNamedInterface else { return false }
if case let .posix(code) = error as? NWError {
if code == .EADDRINUSE { return false }
if code == .EADDRNOTAVAIL || code == .ENETDOWN || code == .ENETUNREACH { return true }
}
return resolveBindAddress(listenInterface) == nil
}

private func waitForInterfaceLocked() {
stopLocked(emitStatus: false)
let currentRun = UUID()
runID = currentRun
// Remember this snapshot so the monitor's initial callback does not
// repeatedly retry an address that still cannot be bound.
lastBoundAddress = resolveBindAddress(listenInterface)
emit(.waitingForInterface(name: listenInterface))
startPathMonitorLocked(currentRun)

// Path updates need not accompany every IPv4 assignment. Poll only
// while waiting, with a modest cadence and leeway; ready/stopped
// servers have no retry timer.
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(
deadline: .now() + interfaceRetryInterval,
repeating: interfaceRetryInterval,
leeway: .milliseconds(250)
)
timer.setEventHandler { [weak self] in
guard let self, self.runID == currentRun,
self.resolveBindAddress(self.listenInterface) != nil
else { return }
self.startLocked()
}
interfaceRetryTimer = timer
timer.resume()
}

/// Watch for the bound NIC's IPv4 changing while the server runs (DHCP
/// renewal, Wi-Fi switch, sleep/wake). loopback and explicit-wildcard are
/// address-stable / address-agnostic and skip the monitor. On a real
/// change we coalesce into a single rebind (stop+start re-resolves the
/// current IPv4 and refreshes the access URLs); a vanished NIC rebinds and
/// then fails loudly via the guard in `startLocked`.
/// current IPv4 and refreshes the access URLs). Keep watching while a NIC
/// is unavailable so its return can recover without toggling the server.
private func startPathMonitorLocked(_ runID: UUID) {
pathMonitor?.cancel()
guard listenInterface != WebRemoteListenTarget.loopback,
listenInterface != WebRemoteListenTarget.any,
lastBoundAddress != nil
else {
guard usesNamedInterface else {
pathMonitor = nil
return
}
Expand All @@ -467,16 +530,18 @@ final class WebRemoteServer: @unchecked Sendable {
}

private func handlePathUpdate(runID: UUID) {
guard runID == self.runID, !pathRestartScheduled else { return }
let current = WebRemoteAddressResolver.currentIPv4(forInterface: listenInterface)
guard runID == self.runID, usesNamedInterface, !pathRestartScheduled else { return }
let current = resolveBindAddress(listenInterface)
guard current != lastBoundAddress else { return }
// NWPathMonitor can fire a burst of updates on a topology change; wait
// for things to settle, then rebind once.
pathRestartScheduled = true
queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self, self.pathRestartScheduled else { return }
guard let self, self.runID == runID, self.pathRestartScheduled else { return }
self.pathRestartScheduled = false
guard self.runID == runID else { return }
// The address may already have returned during the debounce.
// Preserve healthy listeners and their browser sessions in that case.
guard self.resolveBindAddress(self.listenInterface) != self.lastBoundAddress else { return }
webRemoteLogger.info("Web remote interface address changed; rebinding")
self.startLocked()
}
Expand All @@ -496,9 +561,15 @@ final class WebRemoteServer: @unchecked Sendable {
let port = kind == .http ? self.ports.http : self.ports.webSocket
if case .posix(.EADDRINUSE) = error {
self.failLocked(status: .portConflict(port: port))
} else if self.shouldWaitForInterface(after: error) {
self.waitForInterfaceLocked()
} else {
self.failLocked(error.localizedDescription)
}
case let .waiting(error):
if self.shouldWaitForInterface(after: error) {
self.waitForInterfaceLocked()
}
default:
break
}
Expand All @@ -507,7 +578,7 @@ final class WebRemoteServer: @unchecked Sendable {

private func resetCredentialsLocked() {
WebRemoteAccessKeyStore.reset(storage: secretStorage)
WebRemotePortStore.reset()
WebRemotePortStore.reset(defaults: defaults)
startLocked()
}

Expand All @@ -520,11 +591,9 @@ final class WebRemoteServer: @unchecked Sendable {
let addresses = WebRemoteAddressResolver.localIPv4Addresses()
hosts = addresses.isEmpty ? ["127.0.0.1"] : addresses
default:
// Named interface: show its current IPv4. If the interface has gone
// away, fall back to loopback for display — the bind itself will
// fail and surface `.failed`.
let address = WebRemoteAddressResolver.currentIPv4(forInterface: listenInterface)
hosts = [address ?? "127.0.0.1"]
// Advertise the address actually bound, not a newer resolver
// snapshot that the listeners have not moved to yet.
hosts = lastBoundAddress.map { [$0] } ?? []
}
return hosts.map { "http://\($0):\(ports.http)/#token=\(token)" }
}
Expand All @@ -545,6 +614,8 @@ final class WebRemoteServer: @unchecked Sendable {
pathMonitor?.cancel()
pathMonitor = nil
pathRestartScheduled = false
interfaceRetryTimer?.cancel()
interfaceRetryTimer = nil
lastBoundAddress = nil
updateSubscribedPanesLocked()
releaseTerminalSizes(controlledPanes)
Expand All @@ -559,24 +630,7 @@ final class WebRemoteServer: @unchecked Sendable {
}

private func failLocked(status: WebRemoteStatus) {
let controlledPanes = controlledPanesLocked()
runID = nil
httpListener?.cancel()
webSocketListener?.cancel()
httpListener = nil
webSocketListener = nil
clients.values.forEach { $0.cancel() }
clients.removeAll()
authThrottle.removeAll()
readyListeners.removeAll()
token = ""
tokenKey = Data()
pathMonitor?.cancel()
pathMonitor = nil
pathRestartScheduled = false
lastBoundAddress = nil
updateSubscribedPanesLocked()
releaseTerminalSizes(controlledPanes)
stopLocked(emitStatus: false)
webRemoteLogger.error("Web remote failed: \(String(describing: status), privacy: .public)")
emit(status)
}
Expand Down
4 changes: 2 additions & 2 deletions Glint/Workspace/WorkspaceStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1174,8 +1174,7 @@ final class WorkspaceStore: ObservableObject {
}

/// Bind targets currently available on this Mac, for the "Listen on" menu.
/// Snapshotted at init; call `refreshWebRemoteInterfaces()` to rescan after
/// networks change (e.g. joining a different Wi-Fi).
/// Refreshed on server status changes and when opening network settings.
@Published private(set) var webRemoteInterfaceOptions: [WebRemoteInterface] = WebRemoteAddressResolver.interfaces()

func refreshWebRemoteInterfaces() {
Expand Down Expand Up @@ -1833,6 +1832,7 @@ final class WorkspaceStore: ObservableObject {
else { ControlBridge.shared.reapStale() }
WebRemoteServer.shared.setStatusHandler { [weak self] status in
self?.webRemoteStatus = status
self?.refreshWebRemoteInterfaces()
}
WebRemoteServer.shared.setListenInterface(webRemoteListenInterface)
if webRemoteEnabled { WebRemoteServer.shared.start() }
Expand Down
Loading