diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/BrowserTabService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/BrowserTabService.swift index c020e5bbb7ee..ce8eea57ebbb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/BrowserTabService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/BrowserTabService.swift @@ -96,6 +96,8 @@ final class BrowserTabService { private(set) var historyWriters: [String: BrowserHistoryWriter] = [:] /// Stored-history reads in flight, by tab id (cancelled with the page). private var historyFetches: [String: Task] = [:] + /// Record addresses written with no page writer (``writeRecordURL(_:for:)``), awaited by ``flushRecords()``. + private var recordWrites: [String: Task] = [:] /// Restored pages that start once their saved history is read. let historyStarts = BrowserHistoryStarts() /// The App's incognito profiles (the engines' ``BrowserSharedStores``). @@ -432,6 +434,19 @@ final class BrowserTabService { if let tab = tabModel(tabID) { track(page, for: tab) } } + /// Writes `url` to `tab`'s record for a tab with no page to write it (a "Chromium unavailable" tab's typed + /// address), after any earlier such write of the tab; quit's ``flushRecords()`` awaits it. Never an + /// incognito, stand-in or another frontend's tab. + func writeRecordURL(_ url: URL, for tab: TabModel) { + guard tab.isFrontendOwned, !isIncognitoTab(tab.id), !ProvisionalTab.isProvisional(surface: tab.surface) else { return } + let update = update, daemon = daemonForTab(tab), surface = tab.surface, previous = recordWrites[tab.id] + // task-owner: recordWrites; flushRecords awaits it before quit goes on. + recordWrites[tab.id] = Task { + await previous?.value + _ = await update(daemon, surface, BrowserRecordUpdate(url: url.absoluteString, title: nil, favicon: .unchanged)) + } + } + /// The page was released: stop writing. func untrack(_ key: String) { writers.removeValue(forKey: key)?.cancel() @@ -449,5 +464,12 @@ final class BrowserTabService { // task-owner: every flush is awaited before quit goes on. let flushes = historyWriters.values.map { writer in Task { await writer.flushNow() } } for flush in flushes { await flush.value } + // Last, and until none is left: quit's run loop still takes input, and an address typed during any of + // the awaits above or below chains on (or adds) one. The function returns with no suspension after the + // final check, so no typed address of the flush's time is left behind. + while let (id, write) = recordWrites.first { + await write.value + if recordWrites[id] == write { recordWrites[id] = nil } + } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/ChromiumFallbackLog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/ChromiumFallbackLog.swift index 3e2e7b2d8a2a..25253aed8e70 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/ChromiumFallbackLog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/ChromiumFallbackLog.swift @@ -94,4 +94,9 @@ enum BrowserEngineStrings { static var fallbackStartFailed: String { String(localized: "browser.engine.fallback.startFailed", defaultValue: "Chromium couldn’t start, so this tab uses WebKit.", bundle: .module) } + /// A restored Chromium tab whose Chromium failed to start (`CEFUnavailableReason.keepsChromiumTabsOutOfWebKit`). + static var chromiumUnavailableRestoredTab: String { + String(localized: "browser.engine.unavailable.restoredTab", + defaultValue: "Chromium unavailable: this tab isn’t loaded, because WebKit would open it signed out. Quit and reopen cmux to load it.", bundle: .module) + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CmuxNextApp.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CmuxNextApp.swift index 0617fd3ca177..45f894b71fd3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CmuxNextApp.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CmuxNextApp.swift @@ -35,6 +35,11 @@ public struct CmuxNextApp { // This process's only environment writes, then the freeze: a write // after it stops a debug build (ProcessEnvironmentGuard). Self.prepareLaunchEnvironment() + // A second copy of this bundle for this user hands off to the running one and exits, before it + // reads the relaunch-focus record or a session and before CEF (SingleInstanceGuard). + if case .handOff(let activates) = SingleInstanceGuard.current.resolve() { + SingleInstanceGuard.handOff(activates: activates) + } // Pure launch work (action catalog, string tables) overlaps AppKit's start. LaunchWarmup.start() DebugTimings.markLaunch("main.warmup_started") diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings index f65942ef1de7..918a2069915a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings @@ -5125,6 +5125,138 @@ } } }, + "browser.engine.unavailable.restoredTab": { + "comment": "A browser tab's notice when Chromium could not start in this process: the page is not loaded in WebKit (no Chromium cookies there); a restart of cmux is needed.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chromium unavailable: this tab isn’t loaded, because WebKit would open it signed out. Quit and reopen cmux to load it." + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium غير متاح: لم تُحمَّل علامة التبويب هذه لأن WebKit سيفتحها بدون تسجيل الدخول. أغلق cmux وأعد فتحه لتحميلها." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium nije dostupan: ova kartica nije učitana jer bi je WebKit otvorio odjavljenu. Zatvorite i ponovo otvorite cmux da je učitate." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium er ikke tilgængelig: fanen er ikke indlæst, fordi WebKit ville åbne den logget ud. Afslut og åbn cmux igen for at indlæse den." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium nicht verfügbar: Dieser Tab wird nicht geladen, weil WebKit ihn abgemeldet öffnen würde. Beende cmux und öffne es erneut, um ihn zu laden." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium no está disponible: esta pestaña no se carga porque WebKit la abriría sin sesión. Sal de cmux y vuelve a abrirlo para cargarla." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium indisponible : cet onglet n’est pas chargé, car WebKit l’ouvrirait déconnecté. Quittez puis rouvrez cmux pour le charger." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium non disponibile: questa scheda non viene caricata perché WebKit la aprirebbe senza accesso. Esci e riapri cmux per caricarla." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium を使用できません。WebKit ではサインアウトした状態で開くため、このタブは読み込んでいません。読み込むには cmux を終了して開き直してください。" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium មិនអាចប្រើបាន៖ ផ្ទាំងនេះមិនត្រូវបានផ្ទុកទេ ព្រោះ WebKit នឹងបើកវាដោយមិនបានចូល។ ចាកចេញ ហើយបើក cmux ម្តងទៀត ដើម្បីផ្ទុកវា។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium을 사용할 수 없음: WebKit에서는 로그아웃된 상태로 열리므로 이 탭을 불러오지 않았습니다. 불러오려면 cmux를 종료한 후 다시 여세요." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium er ikke tilgjengelig: fanen er ikke lastet inn, fordi WebKit ville åpnet den utlogget. Avslutt og åpne cmux igjen for å laste den inn." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium jest niedostępny: ta karta nie została załadowana, bo WebKit otworzyłby ją bez logowania. Zamknij i ponownie otwórz cmux, aby ją załadować." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium indisponível: esta aba não foi carregada porque o WebKit a abriria sem login. Saia e abra o cmux de novo para carregá-la." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium недоступен: вкладка не загружена, потому что WebKit открыл бы её без входа. Закройте и снова откройте cmux, чтобы загрузить её." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ใช้ Chromium ไม่ได้: ไม่ได้โหลดแท็บนี้ เพราะ WebKit จะเปิดโดยไม่ได้ลงชื่อเข้าใช้ ออกจาก cmux แล้วเปิดใหม่เพื่อโหลด" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium kullanılamıyor: WebKit oturum kapalı açacağı için bu sekme yüklenmedi. Yüklemek için cmux'tan çıkıp yeniden açın." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium недоступний: вкладку не завантажено, бо WebKit відкрив би її без входу. Закрийте й знову відкрийте cmux, щоб завантажити її." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium không khả dụng: tab này chưa được tải vì WebKit sẽ mở nó khi chưa đăng nhập. Thoát và mở lại cmux để tải." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium 不可用:此标签页未加载,因为 WebKit 会以未登录状态打开它。退出并重新打开 cmux 以加载。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "Chromium 無法使用:此分頁未載入,因為 WebKit 會以未登入狀態開啟它。結束並重新開啟 cmux 以載入。" + } + } + } + }, "app.restart.notice": { "extractionState": "manual", "localizations": { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabContentCache+Deferred.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabContentCache+Deferred.swift index daa2474170f9..364c21894c47 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabContentCache+Deferred.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabContentCache+Deferred.swift @@ -1,6 +1,7 @@ import CmuxNextBrowser import Foundation import CmuxNextDaemon +import os /// Restored pages that load nothing until the user starts them (after two /// quick unexpected ends in a row, `defersRestoredPages`). @@ -25,3 +26,55 @@ extension TabContentCache { onBrowserReady?(key) } } + +/// A Chromium tab in a process whose Chromium failed to start is shown as "Chromium unavailable"; its URL never +/// loads in WebKit (`CEFUnavailableReason.keepsChromiumTabsOutOfWebKit`). The notice asks for a restart of cmux; +/// Reload asks the engine again. A new tab never gets here as Chromium once the failure is known: the engine +/// resolver gives it WebKit when it opens. +extension TabContentCache { + private static let unavailableLogger = Logger(subsystem: "com.cmuxterm.app.next", category: "browser.engine") + + /// The page of Chromium record `tab` when Chromium is unavailable for `reason`: the "Chromium unavailable" + /// stand-in when Chromium failed to start, else the WebKit fallback. + func chromiumUnavailable(_ tab: TabModel, url: URL?, reason: CEFUnavailableReason) -> BrowserEntry { + guard reason.keepsChromiumTabsOutOfWebKit else { + return fallBack(tab, url: url, reason: reason) + } + let key = tab.id + Self.unavailableLogger.notice("tab \(key, privacy: .public) waits: Chromium unavailable (\(reason.code, privacy: .public)); its page is not loaded in WebKit") + // The tab's own profile: an incognito tab's URL must not show in another profile's suggestions. + let page = DeferredBrowserTab(id: BrowserTabID(rawValue: key), engine: .cef, profile: browserProfile?(key) ?? .default, + url: url, title: tab.title.isEmpty ? nil : tab.title) + let entry = install(page, for: key) + // The tab the page shows when Reload is pressed: a stand-in's page moves to its daemon tab + // (`moveBrowser`), which rebinds the chrome's key but not this closure. + // A plain Reload starts on the page it shows; only another address was typed. + page.onStart = { [weak self, weak entry] requested in + self?.retryChromium(entry?.chrome.addressBar.tabKey ?? key, shown: requested, typed: requested != url) + } + entry.chrome.showNotice(BrowserEngineStrings.chromiumUnavailableRestoredTab) + return entry + } + + /// Reload, or an address typed, on a "Chromium unavailable" tab: the tab asks for its page again, on the + /// typed address or else its record's current one (an incognito tab's record holds a placeholder, so it keeps + /// the address it showed). While Chromium still cannot serve it (in this process, + /// until cmux restarts) it waits again, and a typed address goes to the tab's record so the restart opens it + /// (never an incognito tab's: its URL stays in memory). + private func retryChromium(_ key: String, shown: URL?, typed isTyped: Bool) { + browserTabs.untrack(key) + browsers.removeValue(forKey: key)?.close() + guard let tab = tabModel(key) else { return } // a remote machine's tab included + let incognito = browserTabs.isIncognitoTab(key) + let typed = isTyped ? shown : nil + let next = typed ?? (incognito ? shown : tab.url.flatMap(URL.init(string:))) + if let reason = browserTabs.cefUnavailable(), reason.keepsChromiumTabsOutOfWebKit { + _ = chromiumUnavailable(tab, url: next, reason: reason) + // Every typed address, also one equal to the record's: an earlier typed write may still be on its way. + if let typed { browserTabs.writeRecordURL(typed, for: tab) } + } else if let entry = browser(for: tab), let typed, typed.absoluteString != tab.url { + entry.tab.load(typed) + } + onBrowserReady?(key) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabContentCache.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabContentCache.swift index b23fda1fcf07..f30f5170d5dd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabContentCache.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabContentCache.swift @@ -266,7 +266,7 @@ final class TabContentCache { return deferred(tab, url: url) } guard tab.browserEngine == BrowserEngineTag.cef.rawValue else { return tracked(browserTabs.historyStarts.page(tab, url: url, cache: self) ?? browser(for: key, url: url), tab) } - if let reason = browserTabs.cefUnavailable() { return fallBack(tab, url: url, reason: reason) } + if let reason = browserTabs.cefUnavailable() { return chromiumUnavailable(tab, url: url, reason: reason) } if !pendingBrowsers.contains(key), let page = ChromiumNewTabNow(cache: self).page(tab, url: url) { return tracked(install(page, for: key), tab) } guard pendingBrowsers.insert(key).inserted, cef.notePoint("page_task") else { return nil } Task { @@ -276,7 +276,7 @@ final class TabContentCache { page = try await makeCEFTab(cef.noted("history", await browserTabs.historyStarts.chromium(tab, cef.noted("configured", await chromiumConfiguration(for: tab, key: key, url: url)), cache: self))) } catch { guard let tab = browserTabs.tabModel(key), browsers[key] == nil else { return } - _ = fallBack(tab, url: url, reason: browserTabs.cefUnavailable() ?? .startFailed(String(describing: error))) + _ = chromiumUnavailable(tab, url: url, reason: browserTabs.cefUnavailable() ?? .startFailed(String(describing: error))) onBrowserReady?(key) return } @@ -357,7 +357,7 @@ final class TabContentCache { /// A WebKit page for a Chromium record, with the fallback recorded and /// the one-time notice shown when this is the first. - private func fallBack(_ tab: TabModel, url: URL?, reason: CEFUnavailableReason) -> BrowserEntry { + func fallBack(_ tab: TabModel, url: URL?, reason: CEFUnavailableReason) -> BrowserEntry { // The page loads again in WebKit, without Chromium's cookies (a second copy of the app, // whose Chromium cannot start while the first holds its profile, shows signed-out pages). let tabID = tab.id, code = reason.code diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFUnavailableReason.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFUnavailableReason.swift index 67874de99693..3d8cff5ae712 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFUnavailableReason.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFUnavailableReason.swift @@ -37,6 +37,15 @@ public nonisolated enum CEFUnavailableReason: Equatable, Sendable { } } + /// True when a Chromium tab must wait in place instead of loading in WebKit: Chromium failed to start in this + /// process (`CefInitialize` runs once per process), and WebKit has none of Chromium's cookies, so a signed-in + /// page would come back signed out (github.com/login?return_to=…, field log 2026-10-26). A build without + /// Chromium or one still downloading it keeps the WebKit fallback. + public var keepsChromiumTabsOutOfWebKit: Bool { + if case .startFailed = self { return true } + return false + } + /// True while the runtime is still on its way (downloading or retrying): /// a Chromium request should start or hurry the download. public var isAwaitingDownload: Bool { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Launch/SingleInstanceGuard.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Launch/SingleInstanceGuard.swift new file mode 100644 index 000000000000..196380886e00 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Launch/SingleInstanceGuard.swift @@ -0,0 +1,98 @@ +import Darwin +public import Foundation + +/// One copy of a cmux bundle per user (field log, 2026-10-26: a NIGHTLY started by direct exec from an ssh session +/// while the first copy ran; its Chromium could not start, since the first copy holds the profile in +/// `//Chromium`, so every restored Chromium tab loaded in WebKit, signed out). +/// +/// The claim is an exclusive `flock` on `//instance.lock`, held for the life of the +/// process (the kernel drops it when the process ends, however it ends). Two launches racing each other cannot +/// both get it. A copy that cannot get it hands its URLs and files to the owner and exits +/// (`SingleInstanceHandoff`), before it reads the relaunch-focus record, a session, the daemon or CEF. +/// `LSMultipleInstancesProhibited` is not used: it does nothing for a direct exec (the copy above). +/// +/// Not guarded: DEV and staging bundles (harnesses run a tagged bundle beside another copy of it; their own bundle +/// ids keep their data apart) and a launch with `CMUX_NEXT_ALLOW_SECOND_INSTANCE=1`. DEV, NIGHTLY and stable have +/// different bundle ids, so different locks. Sparkle's relaunch starts the new build only after the old process +/// exited (its installer waits on the old pid), so the lock is free by then. +public nonisolated struct SingleInstanceGuard: Equatable, Sendable { + public enum Decision: Equatable, Sendable { + /// Start normally (this process owns the claim, or is not guarded). + case launch + /// Another copy owns the claim: hand off to it and exit, never start. `activates` is false for an agent + /// launch (`CMUX_NEXT_NO_ACTIVATE=1`), which then forwards nothing (an open would bring the owner forward). + case handOff(activates: Bool) + } + + /// The outcome of trying to take the claim. + public enum Claim: Equatable, Sendable { + /// This process holds it. + case held + /// Another live process holds it. + case taken + /// No claim could be tried (no Application Support directory, a file error): launch as before. + case unavailable + } + + public static let allowKey = "CMUX_NEXT_ALLOW_SECOND_INSTANCE" + + public var bundleIdentifier: String? + public var environment: [String: String] + + public init(bundleIdentifier: String?, environment: [String: String]) { + self.bundleIdentifier = bundleIdentifier + self.environment = environment + } + + /// Whether this launch takes part in the claim at all. + public var isGuarded: Bool { + guard let bundleIdentifier, !bundleIdentifier.isEmpty else { return false } + return !UpdateTrack.isDevelopmentBundle(bundleIdentifier) && environment[Self.allowKey] != "1" + } + + /// The decision for this launch given the claim's outcome: a taken claim never starts a second copy. + public func decide(_ claim: Claim) -> Decision { + guard isGuarded, claim == .taken else { return .launch } + return .handOff(activates: environment["CMUX_NEXT_NO_ACTIVATE"] != "1") + } +} + +public extension SingleInstanceGuard { + /// This process. + static var current: SingleInstanceGuard { + SingleInstanceGuard(bundleIdentifier: Bundle.main.bundleIdentifier, environment: ProcessInfo.processInfo.environment) + } + + /// The lock file of `bundleIdentifier`. + static func lockURL(bundleIdentifier: String, + applicationSupport: URL? = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first) -> URL? { + applicationSupport?.appending(path: bundleIdentifier).appending(path: "instance.lock") + } + + /// Takes the claim at `url`, keeping the descriptor open (close-on-exec, so no child keeps it) for the life of + /// the process. Only a lock another process holds is `.taken`; any file error launches as before. + static func claim(at url: URL) -> Claim { + // blocking-allow: runs once in `main` before NSApp exists and before any other thread starts; whether this copy may start at all decides everything after it, so nothing can run first (one mkdir of an existing folder) + try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let fd = open(url.path, O_RDWR | O_CREAT | O_CLOEXEC, 0o600) + guard fd >= 0 else { return .unavailable } + guard flock(fd, LOCK_EX | LOCK_NB) == 0 else { + let lockError = errno + close(fd) + return lockError == EWOULDBLOCK ? .taken : .unavailable + } + return .held // fd stays open: the claim lasts until this process ends + } + + /// This launch's lock file, nil when it is not guarded. + var lockURL: URL? { + guard isGuarded, let bundleIdentifier else { return nil } + return Self.lockURL(bundleIdentifier: bundleIdentifier) + } + + /// The decision for this launch; takes the claim when this launch is guarded. + func resolve() -> Decision { + guard let lockURL else { return .launch } + return decide(Self.claim(at: lockURL)) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Launch/SingleInstanceHandoff.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Launch/SingleInstanceHandoff.swift new file mode 100644 index 000000000000..c00cef7ca769 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Launch/SingleInstanceHandoff.swift @@ -0,0 +1,128 @@ +import AppKit +import CmuxNextWakeups +import Darwin +public import Foundation +import os + +/// A second copy's hand-off (``SingleInstanceGuard``): a plain `NSApplication` that takes this launch's open +/// events, starts a helper that waits for this process to exit and then reopens them with +/// `open -b `, and exits. It never makes `CmuxApplication`, a window, a session, the daemon +/// connection or CEF. +/// +/// Delivery is LaunchServices' own once this copy is gone: the running copy gets the URLs and files and comes +/// forward (a bare launch reopens it, as a Dock click does). If that copy quit in the meantime, LaunchServices +/// starts a new one, which takes the claim like any launch. An agent launch (`CMUX_NEXT_NO_ACTIVATE=1`) hands +/// nothing on: any open would bring the person's copy forward. +/// +/// Stop conditions: it exits ``settle`` after launch, the last open event and the last helper (so events AppKit +/// delivers late still go), and at ``deadline`` once the URLs in hand have a helper, whatever arrives after. +@MainActor final class SingleInstanceHandoff: NSObject, NSApplicationDelegate { + nonisolated private static let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "launch") + nonisolated static let settle: Duration = .milliseconds(300) + nonisolated static let deadline: Duration = .seconds(3) + + private var urls: [URL] = [] + /// Exits ``settle`` after the last open event. + private let quiet = DemandTimer(owner: "SingleInstanceHandoff.quiet") + + /// Runs the hand-off; never returns. + static func run(activates: Bool) -> Never { + logger.notice("launch: another copy of this app runs; handing off and exiting before Chromium or the session start") + guard activates else { + logger.notice("launch: an agent launch of a second copy hands nothing on (the running copy would come forward); exiting") + exit(0) + } + // `run` never returns, so this timer lives until the exit. + let deadline = DemandTimer(owner: "SingleInstanceHandoff.deadline") + let handoff = SingleInstanceHandoff() + deadline.schedule(after: Self.deadline) { @MainActor in handoff.finish(deadline: true) } + // No saved windows of this copy: its restoration store would take over the running copy's. + UserDefaults.standard.setVolatileDomain(["ApplePersistenceIgnoreState": true], forName: UserDefaults.argumentDomain) + let app = NSApplication.shared + // No Dock icon. + app.setActivationPolicy(.accessory) + app.delegate = handoff + // A URL launch arrives as `GURL`; take it here (as the app's own delegate does), before AppKit starts. + NSAppleEventManager.shared().setEventHandler(handoff, andSelector: #selector(handleURLEvent(_:reply:)), + forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL)) + withExtendedLifetime((handoff, deadline)) { app.run() } + exit(0) + } + + /// This launch's documents and URLs (AppKit routes both `odoc` and `GURL` here). + func application(_ application: NSApplication, open urls: [URL]) { + self.urls += urls + scheduleFinish() + } + + @objc(handleURLEvent:reply:) private func handleURLEvent(_ event: NSAppleEventDescriptor, reply: NSAppleEventDescriptor) { + guard let text = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))?.stringValue, + let url = URL(string: text) else { return } + urls.append(url) + scheduleFinish() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + scheduleFinish() + } + + private func scheduleFinish() { + quiet.schedule(after: Self.settle) { @MainActor [weak self] in self?.finish() } + } + + /// How many of ``urls`` a helper already carries; nil before the first helper. + private var handed: Int? + /// A helper is starting; its end settles again (``helperStarted()``). + private var starting = false + /// The deadline passed: exit once the URLs in hand have a helper, whatever arrives after. + private var closing = false + + /// Starts a helper for the URLs no helper carries yet (a bare launch: one with none). The process exits + /// only from here, ``settle`` after the last helper started and the last open event came in, with the run + /// loop turning all along: an event AppKit had queued meanwhile reaches the delegate, re-arms the settle and + /// gets its own helper. Each helper waits on this pid, so LaunchServices never routes back here. + private func finish(deadline: Bool = false) { + closing = closing || deadline + guard !starting else { return } + let batch = Array(urls[(handed ?? 0)...]) + // The first helper always starts (a bare launch reopens the running copy); a later one only for new URLs. + guard handed == nil || !batch.isEmpty else { exit(0) } + handed = urls.count + starting = true + let command = Self.reopenCommand(pid: getpid(), bundleIdentifier: Bundle.main.bundleIdentifier ?? "", urls: batch) + // task-owner: the hand-off process; `helperStarted` settles or exits after it. + Task.detached(priority: .userInitiated) { [weak self] in + Self.spawn(command) + Self.logger.notice("launch: \(batch.count, privacy: .public) URL(s) go to the running copy once this one exits") + await self?.helperStarted() + } + } + + private func helperStarted() { + starting = false + if closing { finish(deadline: true) } else { scheduleFinish() } + } + + /// `/bin/sh` that waits on `pid`'s exit (`caffeinate -w`, the kernel's exit event, as + /// `RollbackSwap.relaunchCommand`), then `open -b ` with `urls` (files as paths). + nonisolated static func reopenCommand(pid: pid_t, bundleIdentifier: String, urls: [URL]) -> [String] { + ["/bin/sh", "-c", "p=$0; b=$1; shift; /usr/bin/caffeinate -w \"$p\"; exec /usr/bin/open -b \"$b\" \"$@\"", + String(pid), bundleIdentifier] + urls.map { $0.isFileURL ? $0.path : $0.absoluteString } + } + + private nonisolated static func spawn(_ command: [String]) { + let process = Process() + process.executableURL = URL(filePath: command[0]) + process.arguments = Array(command.dropFirst()) + do { try process.run() } catch { + logger.error("launch: the hand-off helper did not start: \(String(describing: error), privacy: .public)") + } + } +} + +public extension SingleInstanceGuard { + /// Hands this launch to the running copy and exits (``SingleInstanceHandoff``); never returns. + @MainActor static func handOff(activates: Bool) -> Never { + SingleInstanceHandoff.run(activates: activates) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChromiumUnavailableRestoreTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChromiumUnavailableRestoreTests.swift new file mode 100644 index 000000000000..ef4958b0e78a --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChromiumUnavailableRestoreTests.swift @@ -0,0 +1,200 @@ +import AppKit +@testable import CmuxNextApp +import CmuxNextBrowser +@testable import CmuxNextDaemon +import Foundation +import Testing + +/// A copy of cmux whose Chromium could not start never restores a Chromium tab into WebKit, where it has none +/// of Chromium's cookies and a signed-in page came back as github.com/login?return_to=… (field log, 2026-10-26). +/// The tab shows "Chromium unavailable"; Reload tries again. +@MainActor @Suite(.serialized) struct ChromiumUnavailableRestoreTests { + private func cache(_ reason: CEFUnavailableReason?) -> TabContentCache { + let cache = TabContentCache(daemon: DaemonService(), cef: CEFEngine(layout: nil, stores: .inMemory(), signInCallbacks: CEFSignInCallbacks()), fileInput: NoFileInput()) + cache.browserTabs.cefUnavailable = { reason } + return cache + } + + private func restoredChromiumTab(_ surface: UInt64) -> TabModel { + TabModel(TabSnapshot(surface: SurfaceID(rawValue: surface), kind: .browser, url: "https://github.com/settings/tokens", + browserRenderer: "frontend", browserEngine: "cef")) + } + + @Test func onlyAStartFailureHoldsTheTab() { + #expect(CEFUnavailableReason.startFailed("CefInitialize failed").keepsChromiumTabsOutOfWebKit) + #expect(!CEFUnavailableReason.notBundled.keepsChromiumTabsOutOfWebKit, "a build without Chromium keeps its WebKit fallback") + #expect(!CEFUnavailableReason.downloading(fraction: nil).keepsChromiumTabsOutOfWebKit) + #expect(!CEFUnavailableReason.downloadFailed("offline").keepsChromiumTabsOutOfWebKit) + } + + @Test func aBuildWithoutChromiumStillOpensTheTabInWebKit() throws { + let cache = cache(.notBundled) + let tab = restoredChromiumTab(63) + defer { cache.release(tab.id) } + let entry = try #require(cache.browser(for: tab)) + #expect(!(entry.tab is DeferredBrowserTab)) + #expect(entry.tab.engineKind == .webkit) + } + + @Test func aRestoredChromiumTabWaitsInsteadOfLoadingInWebKit() throws { + let cache = cache(.startFailed("CefInitialize failed")) + let tab = restoredChromiumTab(61) + defer { cache.release(tab.id) } + let entry = try #require(cache.browser(for: tab)) + let page = try #require(entry.tab as? DeferredBrowserTab, "no WebKit page: nothing loads") + #expect(page.engineKind == .cef) + #expect(page.state.url?.absoluteString == "https://github.com/settings/tokens", "the address bar keeps the tab's page") + } + + @Test func reloadTriesAgainAndWaitsAgainWhileChromiumStillCannotStart() throws { + let cache = cache(.startFailed("CefInitialize failed")) + let tab = restoredChromiumTab(62) + defer { cache.release(tab.id) } + cache.findTab = { $0 == tab.id ? tab : nil } + var ready: [String] = [] + cache.onBrowserReady = { ready.append($0) } + let first = try #require(cache.browser(for: tab)) + first.tab.reload() + #expect(ready == [tab.id]) + let again = try #require(cache.existingBrowser(tab.id)) + #expect(again !== first, "the retry asked for the page again") + #expect(again.tab is DeferredBrowserTab, "still no WebKit page") + } + + /// An address typed into a "Chromium unavailable" tab is kept: the tab waits again on that address. + @Test func aTypedAddressIsKeptWhileChromiumStillCannotStart() async throws { + let cache = cache(.startFailed("CefInitialize failed")) + let tab = restoredChromiumTab(64) + defer { cache.release(tab.id) } + cache.findTab = { $0 == tab.id ? tab : nil } + let first = try #require(cache.browser(for: tab)) + var written: [String] = [] + cache.browserTabs.update = { _, _, update in + if let url = update.url { written.append(url) } + return true + } + let typed = try #require(URL(string: "https://example.com/typed")) + first.tab.load(typed) + let again = try #require(cache.existingBrowser(tab.id)?.tab as? DeferredBrowserTab) + #expect(again.state.url == typed) + // Quit's flush waits for the write, so a quit right away still reopens the typed address. + await cache.browserTabs.flushRecords() + #expect(written == [typed.absoluteString], "the restart the notice asks for opens the typed address") + } + + /// Typing B and then the record's own address A back before B's write lands: the restart opens A, not B. + @Test func typingTheRecordsAddressBackWinsOverAnEarlierTypedWrite() async throws { + let cache = cache(.startFailed("CefInitialize failed")) + let tab = restoredChromiumTab(67) + defer { cache.release(tab.id) } + cache.findTab = { $0 == tab.id ? tab : nil } + var written: [String] = [] + cache.browserTabs.update = { _, _, update in + for _ in 0..<5 { await Task.yield() } // the daemon answers later + if let url = update.url { written.append(url) } + return true + } + let first = try #require(cache.browser(for: tab)) + let original = try #require(tab.url.flatMap(URL.init(string:))) + let typed = try #require(URL(string: "https://example.com/typed")) + first.tab.load(typed) + let second = try #require(cache.existingBrowser(tab.id)?.tab as? DeferredBrowserTab) + second.load(original) + await cache.browserTabs.flushRecords() + #expect(written == [typed.absoluteString, original.absoluteString], "the last typed address is the record's") + } + + /// An address typed while quit waits for the records is waited for too. + @Test func quitsFlushWaitsForAnAddressTypedDuringIt() async throws { + let cache = cache(.startFailed("CefInitialize failed")) + let tab = restoredChromiumTab(68) + defer { cache.release(tab.id) } + cache.findTab = { $0 == tab.id ? tab : nil } + let typed = try #require(URL(string: "https://example.com/typed")) + let late = try #require(URL(string: "https://example.com/late")) + var written: [String] = [] + cache.browserTabs.update = { [weak cache] _, _, update in + for _ in 0..<5 { await Task.yield() } + if let url = update.url { written.append(url) } + // The person types again while quit waits. + if written.count == 1 { (cache?.existingBrowser(tab.id)?.tab as? DeferredBrowserTab)?.load(late) } + return true + } + let first = try #require(cache.browser(for: tab)) + first.tab.load(typed) + await cache.browserTabs.flushRecords() + #expect(written == [typed.absoluteString, late.absoluteString], "the flush ends after the late write") + } + + /// A stand-in tab's "Chromium unavailable" page that moved to its daemon tab retries under the daemon tab's id. + @Test func aMovedStandInRetriesUnderItsDaemonTab() throws { + let cache = cache(.startFailed("CefInitialize failed")) + let provisional = ProvisionalTab() + let standIn = TabModel(TabSnapshot(surface: provisional.surface, tabResourceID: ResourceID(rawValue: provisional.id), kind: .browser, + url: "https://example.com/", browserRenderer: "frontend", browserEngine: "cef")) + let daemonTab = TabModel(TabSnapshot(surface: SurfaceID(rawValue: 65), tabResourceID: ResourceID(rawValue: "tab_65"), kind: .browser, + url: "https://example.com/", browserRenderer: "frontend", browserEngine: "cef")) + defer { cache.release(daemonTab.id) } + cache.findTab = { $0 == daemonTab.id ? daemonTab : nil } + let first = try #require(cache.chromiumUnavailable(standIn, url: URL(string: "https://example.com/"), reason: .startFailed("x")) as BrowserEntry?) + #expect(cache.moveBrowser(standIn.id, to: daemonTab)) + var ready: [String] = [] + cache.onBrowserReady = { ready.append($0) } + first.tab.reload() + #expect(ready == [daemonTab.id]) + #expect(cache.existingBrowser(daemonTab.id)?.tab is DeferredBrowserTab) + } + + /// A plain Reload never writes the address it showed over the record: another client moved the tab, and + /// the retry follows the record. + @Test func aPlainReloadFollowsAChangedRecordAndWritesNothing() async throws { + let cache = cache(.startFailed("CefInitialize failed")) + let tab = restoredChromiumTab(66) + let moved = TabModel(TabSnapshot(surface: SurfaceID(rawValue: 66), kind: .browser, url: "https://example.com/moved", + browserRenderer: "frontend", browserEngine: "cef")) + defer { cache.release(tab.id) } + var current = tab + cache.findTab = { $0 == tab.id ? current : nil } + var written: [String] = [] + cache.browserTabs.update = { _, _, update in + if let url = update.url { written.append(url) } + return true + } + let first = try #require(cache.browser(for: tab)) + current = moved // workspace.open_url moved the record + first.tab.reload() + for _ in 0..<10 { await Task.yield() } + #expect(written.isEmpty) + let again = try #require(cache.existingBrowser(tab.id)?.tab as? DeferredBrowserTab) + #expect(again.state.url?.absoluteString == "https://example.com/moved") + } + + /// An incognito tab's record holds a placeholder: Reload keeps the address the tab showed, a typed one stays + /// in memory, and nothing is written. + @Test func anIncognitoTabKeepsItsAddressAcrossRetries() async throws { + let cache = cache(.startFailed("CefInitialize failed")) + let tab = TabModel(TabSnapshot(surface: SurfaceID(rawValue: 67), kind: .browser, url: "about:blank", + browserRenderer: "frontend", browserEngine: "cef")) + defer { cache.release(tab.id) } + cache.findTab = { $0 == tab.id ? tab : nil } + cache.browserTabs.isIncognitoTab = { $0 == tab.id } + var written: [String] = [] + cache.browserTabs.update = { _, _, update in + if let url = update.url { written.append(url) } + return true + } + let secret = try #require(URL(string: "https://private.example/")) + let first = cache.chromiumUnavailable(tab, url: secret, reason: .startFailed("x")) + first.tab.reload() + let second = try #require(cache.existingBrowser(tab.id)?.tab as? DeferredBrowserTab) + #expect(second.state.url == secret) + let typed = try #require(URL(string: "https://private.example/typed")) + second.load(typed) + let third = try #require(cache.existingBrowser(tab.id)?.tab as? DeferredBrowserTab) + #expect(third.state.url == typed) + third.reload() + #expect((cache.existingBrowser(tab.id)?.tab as? DeferredBrowserTab)?.state.url == typed) + for _ in 0..<10 { await Task.yield() } + #expect(written.isEmpty, "an incognito address never reaches the record") + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SingleInstanceGuardTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SingleInstanceGuardTests.swift new file mode 100644 index 000000000000..4b8e9a827bb2 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SingleInstanceGuardTests.swift @@ -0,0 +1,90 @@ +import AppKit +import Darwin +@testable import CmuxNextUpdater +import Foundation +import Testing + +/// One copy of a cmux bundle per user: a second copy (a direct exec from an ssh session, `open -n`) hands off to +/// the running one before Chromium or the session start (field log, 2026-10-26). +@Suite struct SingleInstanceGuardTests { + private func guardFor(_ bundle: String? = "com.cmuxterm.app.nightly", environment: [String: String] = [:]) -> SingleInstanceGuard { + SingleInstanceGuard(bundleIdentifier: bundle, environment: environment) + } + + @Test func aSecondCopyNeverStarts() { + #expect(guardFor().decide(.taken) == .handOff(activates: true)) + } + + @Test func theOwnerAndUnclaimableLaunchesStart() { + #expect(guardFor().decide(.held) == .launch) + #expect(guardFor().decide(.unavailable) == .launch, "no lock file: launch as before") + } + + @Test func anAgentLaunchHandsOffWithoutActivating() { + #expect(guardFor(environment: ["CMUX_NEXT_NO_ACTIVATE": "1"]).decide(.taken) == .handOff(activates: false)) + } + + @Test func devBundlesAndTheOptOutAreNotGuarded() { + for bundle in ["com.cmuxterm.app.debug", "com.cmuxterm.app.debug.my-tag", "com.cmuxterm.app.staging"] { + #expect(!guardFor(bundle).isGuarded, "\(bundle): harnesses run a tagged bundle twice") + #expect(guardFor(bundle).decide(.taken) == .launch) + } + #expect(guardFor(environment: [SingleInstanceGuard.allowKey: "1"]).decide(.taken) == .launch) + #expect(guardFor(nil).decide(.taken) == .launch) + } + + @Test func releaseChannelsAreGuarded() { + for bundle in ["com.cmuxterm.app.next", "com.cmuxterm.app.nightly", "com.cmuxterm.app"] { + #expect(guardFor(bundle).decide(.taken) == .handOff(activates: true), "\(bundle)") + } + } + + /// The claim is a lifetime flock: a second descriptor (a second copy) finds it taken. + @Test func theClaimIsExclusive() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: "single-instance-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appending(path: "instance.lock") + #expect(SingleInstanceGuard.claim(at: url) == .held) + // flock is per open file description: a new open in this process stands in for a second copy. + let other = open(url.path, O_RDWR | O_CLOEXEC) + defer { close(other) } + #expect(flock(other, LOCK_EX | LOCK_NB) != 0, "held by the first descriptor") + #expect(SingleInstanceGuard.claim(at: url) == .taken) + } + + @Test func eachBundleHasItsOwnLock() throws { + let support = URL(filePath: "/tmp/support") + #expect(SingleInstanceGuard.lockURL(bundleIdentifier: "com.cmuxterm.app.nightly", applicationSupport: support)?.path + == "/tmp/support/com.cmuxterm.app.nightly/instance.lock") + #expect(SingleInstanceGuard.lockURL(bundleIdentifier: "com.cmuxterm.app.next", applicationSupport: support) + != SingleInstanceGuard.lockURL(bundleIdentifier: "com.cmuxterm.app.nightly", applicationSupport: support)) + } + + /// The helper waits on the second copy's exit, then hands its URLs (files as paths) to the bundle's running + /// copy through LaunchServices. `$0` is the pid and `$1` the bundle id; `shift` leaves only the URLs. + @Test func theReopenHelperOpensTheURLsWithThisBundleAfterThisPidExits() throws { + let web = try #require(URL(string: "https://github.com/settings")) + let file = URL(filePath: "/tmp/a b.md") + let command = SingleInstanceHandoff.reopenCommand(pid: 4242, bundleIdentifier: "com.cmuxterm.app.nightly", urls: [web, file]) + #expect(command[0] == "/bin/sh") + #expect(command[1] == "-c") + #expect(command[2].contains("caffeinate -w \"$p\"")) + #expect(command[2].contains("open -b \"$b\" \"$@\"")) + #expect(Array(command[3...]) == ["4242", "com.cmuxterm.app.nightly", "https://github.com/settings", "/tmp/a b.md"]) + } + + /// The helper really runs: with an exited pid it goes straight on (here `open` is replaced by `echo`). + @Test func theReopenHelperPassesOnlyTheURLs() throws { + var command = SingleInstanceHandoff.reopenCommand(pid: 999_999, bundleIdentifier: "com.example.id", + urls: [try #require(URL(string: "https://a.example/"))]) + command[2] = command[2].replacingOccurrences(of: "/usr/bin/caffeinate -w \"$p\"; exec /usr/bin/open", with: "exec /bin/echo") + let process = Process(), pipe = Pipe() + process.executableURL = URL(filePath: command[0]) + process.arguments = Array(command.dropFirst()) + process.standardOutput = pipe + try process.run() + process.waitUntilExit() + let output = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + #expect(output == "-b com.example.id https://a.example/\n") + } +} diff --git a/plans/cmux-next/arch-ratchet.json b/plans/cmux-next/arch-ratchet.json index 96360e690cce..269875e12d28 100644 --- a/plans/cmux-next/arch-ratchet.json +++ b/plans/cmux-next/arch-ratchet.json @@ -579,6 +579,12 @@ "row": "observer_sites", "allow": 7, "why": "DesktopWallpaperWatch (from #23009): macOS posts no public notice when the desktop picture changes, so one watch per app (owned by BackdropImageStore.shared, injected into every WindowMaterialView) follows the wallpaper store folder with a file-system source (not an observer) and re-reads the main screen's picture on the seven system moments that can change it or that it must catch up after: macOS's com.apple.desktop notice, a Space switch, waking, a screen-parameter change, a window's occlusion changing (it reads nothing while no window shows, so a window shown, unhidden or uncovered catches it up), a window becoming key and a window changing screens. Each is observed once per app. The original PR's app-activation and AppleInterfaceThemeChanged observers were dropped (the view's own appearance callback covers the second); Codex review of 7bb4472 asked for wake and com.apple.desktop back." + }, + { + "date": "2026-10-26", + "row": "blocking_allow_escapes", + "allow": 1, + "why": "CmuxNextUpdater SingleInstanceGuard.claim: the single-instance claim (mkdir of /, open, flock) runs once in CmuxNextApp.main before NSApp exists and before any other thread starts. Whether this process may start at all (a second copy must not reach CEF or the session) decides everything after it, so it cannot move off the main thread or after launch. Codex xhigh review of PR #25918." } ], "observer_wrappers": [