Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
91d1a79
cmux NEXT: a second copy hands off to the running one; no restored Ch…
teamleaderleo Oct 27, 2026
298df6b
Hand-off: Apple event class and id as AEEventClass/AEEventID
teamleaderleo Oct 27, 2026
18a2625
Chromium unavailable: the retry finds the tab on any machine
teamleaderleo Oct 27, 2026
adebc01
Single instance: a lifetime flock claim; hand-off settles, has an off…
teamleaderleo Oct 27, 2026
c7c3379
Single instance: never start on a taken claim; replies, retries and r…
teamleaderleo Oct 27, 2026
fd0b544
Single instance: guard and hand-off in CmuxNextUpdater; the Chromium …
teamleaderleo Oct 27, 2026
0610f4b
arch-ratchet: the single-instance claim's launch-time blocking allowance
teamleaderleo Oct 27, 2026
7cb6b3e
Single instance: the hand-off's public entry on SingleInstanceGuard
teamleaderleo Oct 27, 2026
3eea3f7
Single instance hand-off: each answered event leaves the queue; deadl…
teamleaderleo Oct 27, 2026
b627fdf
Single instance hand-off: give up at an absolute instant from before …
teamleaderleo Oct 27, 2026
1021a3e
Single instance hand-off: timing constants nonisolated
teamleaderleo Oct 27, 2026
3e37b2e
Single instance hand-off: a reply timeout is never resent; activate o…
teamleaderleo Oct 27, 2026
980f5d1
Chromium unavailable: unwrap the record URL
teamleaderleo Oct 27, 2026
01d2129
Single instance: no guard bypass on recovery; Chromium unavailable: a…
teamleaderleo Oct 27, 2026
800832c
Single instance hand-off: DemandTimer deadlines; a timed-out event is…
teamleaderleo Oct 27, 2026
1992407
Single instance hand-off: the hard-exit timer is owned by run, not a …
teamleaderleo Oct 27, 2026
a04e5ba
Single instance hand-off through LaunchServices: the second copy exit…
teamleaderleo Oct 27, 2026
2190ef4
Single instance hand-off takes GURL launches; Chromium unavailable: a…
teamleaderleo Oct 27, 2026
21ccfb8
Single instance hand-off: explicit Objective-C selector for the GURL …
teamleaderleo Oct 27, 2026
c2d4859
Chromium unavailable: an incognito tab keeps the address it showed ac…
teamleaderleo Oct 27, 2026
f9121a7
Single instance hand-off: an open event that arrives while the helper…
teamleaderleo Oct 27, 2026
8a82eb3
Single instance: the hand-off settles after each helper before it exi…
teamleaderleo Oct 27, 2026
39e043b
Chromium unavailable: every typed address is written, also the record…
teamleaderleo Oct 27, 2026
a3e90c9
Chromium unavailable: quit's record flush waits until no typed-addres…
teamleaderleo Oct 27, 2026
6d8f7bc
Chromium unavailable: quit drains typed-address writes after the hist…
teamleaderleo Oct 27, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>] = [:]
/// Record addresses written with no page writer (``writeRecordURL(_:for:)``), awaited by ``flushRecords()``.
private var recordWrites: [String: Task<Void, Never>] = [:]
/// Restored pages that start once their saved history is read.
let historyStarts = BrowserHistoryStarts()
/// The App's incognito profiles (the engines' ``BrowserSharedStores``).
Expand Down Expand Up @@ -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()
Expand All @@ -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 }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
5 changes: 5 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/CmuxNextApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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`).
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading