Fix SSH workspace priming and restore state
This commit is contained in:
parent
2eae782739
commit
5e7458b920
7 changed files with 345 additions and 51 deletions
|
|
@ -1987,16 +1987,26 @@ struct ContentView: View {
|
||||||
let isSelectedWorkspace = selectedWorkspaceId == tab.id
|
let isSelectedWorkspace = selectedWorkspaceId == tab.id
|
||||||
let isRetiringWorkspace = retiringWorkspaceId == tab.id
|
let isRetiringWorkspace = retiringWorkspaceId == tab.id
|
||||||
let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id)
|
let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id)
|
||||||
|
let isRenderedVisible = isSelectedWorkspace || isRetiringWorkspace
|
||||||
|
let isWorkspaceVisibleToPanels = isRenderedVisible || shouldPrimeInBackground
|
||||||
|
let workspaceRenderOpacity: Double = {
|
||||||
|
if isRenderedVisible {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if shouldPrimeInBackground {
|
||||||
|
return 0.001
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}()
|
||||||
// Keep the retiring workspace visible during handoff, but never input-active.
|
// Keep the retiring workspace visible during handoff, but never input-active.
|
||||||
// Allowing both selected+retiring workspaces to be input-active lets the
|
// Allowing both selected+retiring workspaces to be input-active lets the
|
||||||
// old workspace steal first responder (notably with WKWebView), which can
|
// old workspace steal first responder (notably with WKWebView), which can
|
||||||
// delay handoff completion and make browser returns feel laggy.
|
// delay handoff completion and make browser returns feel laggy.
|
||||||
let isInputActive = isSelectedWorkspace
|
let isInputActive = isSelectedWorkspace
|
||||||
let isVisible = isSelectedWorkspace || isRetiringWorkspace
|
|
||||||
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
|
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
|
||||||
WorkspaceContentView(
|
WorkspaceContentView(
|
||||||
workspace: tab,
|
workspace: tab,
|
||||||
isWorkspaceVisible: isVisible,
|
isWorkspaceVisible: isWorkspaceVisibleToPanels,
|
||||||
isWorkspaceInputActive: isInputActive,
|
isWorkspaceInputActive: isInputActive,
|
||||||
workspacePortalPriority: portalPriority,
|
workspacePortalPriority: portalPriority,
|
||||||
onThemeRefreshRequest: { reason, eventId, source, payloadHex in
|
onThemeRefreshRequest: { reason, eventId, source, payloadHex in
|
||||||
|
|
@ -2009,9 +2019,9 @@ struct ContentView: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.opacity(isVisible ? 1 : 0)
|
.opacity(workspaceRenderOpacity)
|
||||||
.allowsHitTesting(isSelectedWorkspace)
|
.allowsHitTesting(isSelectedWorkspace)
|
||||||
.accessibilityHidden(!isVisible)
|
.accessibilityHidden(!isRenderedVisible)
|
||||||
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
|
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
|
||||||
.task(id: shouldPrimeInBackground ? tab.id : nil) {
|
.task(id: shouldPrimeInBackground ? tab.id : nil) {
|
||||||
await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id)
|
await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id)
|
||||||
|
|
|
||||||
|
|
@ -1798,6 +1798,13 @@ final class BrowserPanel: Panel, ObservableObject {
|
||||||
private let developerToolsRestoreRetryMaxAttempts: Int = 40
|
private let developerToolsRestoreRetryMaxAttempts: Int = 40
|
||||||
private var remoteProxyEndpoint: BrowserProxyEndpoint?
|
private var remoteProxyEndpoint: BrowserProxyEndpoint?
|
||||||
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
|
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
|
||||||
|
private let usesRemoteWorkspaceProxy: Bool
|
||||||
|
private struct PendingRemoteNavigation {
|
||||||
|
let request: URLRequest
|
||||||
|
let recordTypedNavigation: Bool
|
||||||
|
let preserveRestoredSessionHistory: Bool
|
||||||
|
}
|
||||||
|
private var pendingRemoteNavigation: PendingRemoteNavigation?
|
||||||
private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35
|
private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35
|
||||||
private var developerToolsDetachedOpenGraceDeadline: Date?
|
private var developerToolsDetachedOpenGraceDeadline: Date?
|
||||||
private var developerToolsTransitionTargetVisible: Bool?
|
private var developerToolsTransitionTargetVisible: Bool?
|
||||||
|
|
@ -2045,15 +2052,17 @@ final class BrowserPanel: Panel, ObservableObject {
|
||||||
initialURL: URL? = nil,
|
initialURL: URL? = nil,
|
||||||
bypassInsecureHTTPHostOnce: String? = nil,
|
bypassInsecureHTTPHostOnce: String? = nil,
|
||||||
proxyEndpoint: BrowserProxyEndpoint? = nil,
|
proxyEndpoint: BrowserProxyEndpoint? = nil,
|
||||||
isRemoteWorkspace: Bool = false
|
isRemoteWorkspace: Bool = false,
|
||||||
|
remoteWebsiteDataStoreIdentifier: UUID? = nil
|
||||||
) {
|
) {
|
||||||
self.id = UUID()
|
self.id = UUID()
|
||||||
self.workspaceId = workspaceId
|
self.workspaceId = workspaceId
|
||||||
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
|
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
|
||||||
self.remoteProxyEndpoint = proxyEndpoint
|
self.remoteProxyEndpoint = proxyEndpoint
|
||||||
|
self.usesRemoteWorkspaceProxy = isRemoteWorkspace
|
||||||
self.browserThemeMode = BrowserThemeSettings.mode()
|
self.browserThemeMode = BrowserThemeSettings.mode()
|
||||||
self.websiteDataStore = isRemoteWorkspace
|
self.websiteDataStore = isRemoteWorkspace
|
||||||
? WKWebsiteDataStore(forIdentifier: self.id)
|
? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? workspaceId)
|
||||||
: .default()
|
: .default()
|
||||||
|
|
||||||
let webView = Self.makeWebView(websiteDataStore: websiteDataStore)
|
let webView = Self.makeWebView(websiteDataStore: websiteDataStore)
|
||||||
|
|
@ -2143,6 +2152,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
||||||
guard remoteProxyEndpoint != endpoint else { return }
|
guard remoteProxyEndpoint != endpoint else { return }
|
||||||
remoteProxyEndpoint = endpoint
|
remoteProxyEndpoint = endpoint
|
||||||
applyRemoteProxyConfigurationIfAvailable()
|
applyRemoteProxyConfigurationIfAvailable()
|
||||||
|
resumePendingRemoteNavigationIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) {
|
func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) {
|
||||||
|
|
@ -2785,6 +2795,46 @@ final class BrowserPanel: Panel, ObservableObject {
|
||||||
preserveRestoredSessionHistory: Bool = false
|
preserveRestoredSessionHistory: Bool = false
|
||||||
) {
|
) {
|
||||||
guard let url = request.url else { return }
|
guard let url = request.url else { return }
|
||||||
|
if usesRemoteWorkspaceProxy, remoteProxyEndpoint == nil {
|
||||||
|
pendingRemoteNavigation = PendingRemoteNavigation(
|
||||||
|
request: request,
|
||||||
|
recordTypedNavigation: recordTypedNavigation,
|
||||||
|
preserveRestoredSessionHistory: preserveRestoredSessionHistory
|
||||||
|
)
|
||||||
|
shouldRenderWebView = true
|
||||||
|
currentURL = Self.remoteProxyDisplayURL(for: url) ?? url
|
||||||
|
navigationDelegate?.lastAttemptedURL = url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
performNavigation(
|
||||||
|
request: request,
|
||||||
|
originalURL: url,
|
||||||
|
recordTypedNavigation: recordTypedNavigation,
|
||||||
|
preserveRestoredSessionHistory: preserveRestoredSessionHistory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resumePendingRemoteNavigationIfNeeded() {
|
||||||
|
guard remoteProxyEndpoint != nil,
|
||||||
|
let pendingRemoteNavigation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.pendingRemoteNavigation = nil
|
||||||
|
guard let originalURL = pendingRemoteNavigation.request.url else { return }
|
||||||
|
performNavigation(
|
||||||
|
request: pendingRemoteNavigation.request,
|
||||||
|
originalURL: originalURL,
|
||||||
|
recordTypedNavigation: pendingRemoteNavigation.recordTypedNavigation,
|
||||||
|
preserveRestoredSessionHistory: pendingRemoteNavigation.preserveRestoredSessionHistory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performNavigation(
|
||||||
|
request: URLRequest,
|
||||||
|
originalURL: URL,
|
||||||
|
recordTypedNavigation: Bool,
|
||||||
|
preserveRestoredSessionHistory: Bool
|
||||||
|
) {
|
||||||
if !preserveRestoredSessionHistory {
|
if !preserveRestoredSessionHistory {
|
||||||
abandonRestoredSessionHistoryIfNeeded()
|
abandonRestoredSessionHistoryIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
@ -2793,9 +2843,9 @@ final class BrowserPanel: Panel, ObservableObject {
|
||||||
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
|
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
|
||||||
shouldRenderWebView = true
|
shouldRenderWebView = true
|
||||||
if recordTypedNavigation {
|
if recordTypedNavigation {
|
||||||
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
|
BrowserHistoryStore.shared.recordTypedNavigation(url: originalURL)
|
||||||
}
|
}
|
||||||
navigationDelegate?.lastAttemptedURL = url
|
navigationDelegate?.lastAttemptedURL = originalURL
|
||||||
browserLoadRequest(effectiveRequest, in: webView)
|
browserLoadRequest(effectiveRequest, in: webView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -943,6 +943,9 @@ class TabManager: ObservableObject {
|
||||||
newWorkspace.owningTabManager = self
|
newWorkspace.owningTabManager = self
|
||||||
wireClosedBrowserTracking(for: newWorkspace)
|
wireClosedBrowserTracking(for: newWorkspace)
|
||||||
let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride)
|
let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride)
|
||||||
|
if eagerLoadTerminal && !select {
|
||||||
|
requestBackgroundWorkspaceLoad(for: newWorkspace.id)
|
||||||
|
}
|
||||||
var updatedTabs = snapshot.tabs
|
var updatedTabs = snapshot.tabs
|
||||||
if insertIndex >= 0 && insertIndex <= updatedTabs.count {
|
if insertIndex >= 0 && insertIndex <= updatedTabs.count {
|
||||||
updatedTabs.insert(newWorkspace, at: insertIndex)
|
updatedTabs.insert(newWorkspace, at: insertIndex)
|
||||||
|
|
@ -959,8 +962,10 @@ class TabManager: ObservableObject {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if eagerLoadTerminal {
|
if eagerLoadTerminal {
|
||||||
|
if select {
|
||||||
newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded()
|
newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if select {
|
if select {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id)
|
debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id)
|
||||||
|
|
@ -1169,21 +1174,33 @@ class TabManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
||||||
guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return }
|
guard !pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return }
|
||||||
|
var updated = pendingBackgroundWorkspaceLoadIds
|
||||||
|
updated.insert(workspaceId)
|
||||||
|
pendingBackgroundWorkspaceLoadIds = updated
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
||||||
guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return }
|
guard pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return }
|
||||||
|
var updated = pendingBackgroundWorkspaceLoadIds
|
||||||
|
updated.remove(workspaceId)
|
||||||
|
pendingBackgroundWorkspaceLoadIds = updated
|
||||||
}
|
}
|
||||||
|
|
||||||
func retainDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
|
func retainDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
|
||||||
guard !workspaceIds.isEmpty else { return }
|
guard !workspaceIds.isEmpty else { return }
|
||||||
debugPinnedWorkspaceLoadIds.formUnion(workspaceIds)
|
var updated = debugPinnedWorkspaceLoadIds
|
||||||
|
updated.formUnion(workspaceIds)
|
||||||
|
guard updated != debugPinnedWorkspaceLoadIds else { return }
|
||||||
|
debugPinnedWorkspaceLoadIds = updated
|
||||||
}
|
}
|
||||||
|
|
||||||
func releaseDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
|
func releaseDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
|
||||||
guard !workspaceIds.isEmpty else { return }
|
guard !workspaceIds.isEmpty else { return }
|
||||||
debugPinnedWorkspaceLoadIds.subtract(workspaceIds)
|
var updated = debugPinnedWorkspaceLoadIds
|
||||||
|
updated.subtract(workspaceIds)
|
||||||
|
guard updated != debugPinnedWorkspaceLoadIds else { return }
|
||||||
|
debugPinnedWorkspaceLoadIds = updated
|
||||||
}
|
}
|
||||||
|
|
||||||
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
|
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
|
||||||
|
|
@ -4046,11 +4063,13 @@ extension TabManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
|
func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
|
||||||
let workspaceSnapshots = tabs
|
let restorableTabs = tabs
|
||||||
|
.filter { !$0.isRemoteWorkspace }
|
||||||
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
|
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
|
||||||
|
let workspaceSnapshots = restorableTabs
|
||||||
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
|
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
|
||||||
let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in
|
let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in
|
||||||
tabs.firstIndex(where: { $0.id == selectedTabId })
|
restorableTabs.firstIndex(where: { $0.id == selectedTabId })
|
||||||
}
|
}
|
||||||
return SessionTabManagerSnapshot(
|
return SessionTabManagerSnapshot(
|
||||||
selectedWorkspaceIndex: selectedWorkspaceIndex,
|
selectedWorkspaceIndex: selectedWorkspaceIndex,
|
||||||
|
|
|
||||||
|
|
@ -6256,9 +6256,12 @@ final class Workspace: Identifiable, ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func remoteTerminalStartupCommand() -> String? {
|
private func remoteTerminalStartupCommand() -> String? {
|
||||||
guard hasActiveRemoteTerminalSessions else { return nil }
|
guard let command = remoteConfiguration?.terminalStartupCommand?
|
||||||
return remoteConfiguration?.terminalStartupCommand?
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
!command.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return command
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new browser panel split
|
/// Create a new browser panel split
|
||||||
|
|
@ -6288,7 +6291,8 @@ final class Workspace: Identifiable, ObservableObject {
|
||||||
workspaceId: id,
|
workspaceId: id,
|
||||||
initialURL: url,
|
initialURL: url,
|
||||||
proxyEndpoint: remoteProxyEndpoint,
|
proxyEndpoint: remoteProxyEndpoint,
|
||||||
isRemoteWorkspace: isRemoteWorkspace
|
isRemoteWorkspace: isRemoteWorkspace,
|
||||||
|
remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil
|
||||||
)
|
)
|
||||||
panels[browserPanel.id] = browserPanel
|
panels[browserPanel.id] = browserPanel
|
||||||
panelTitles[browserPanel.id] = browserPanel.displayTitle
|
panelTitles[browserPanel.id] = browserPanel.displayTitle
|
||||||
|
|
@ -6357,7 +6361,8 @@ final class Workspace: Identifiable, ObservableObject {
|
||||||
initialURL: url,
|
initialURL: url,
|
||||||
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce,
|
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce,
|
||||||
proxyEndpoint: remoteProxyEndpoint,
|
proxyEndpoint: remoteProxyEndpoint,
|
||||||
isRemoteWorkspace: isRemoteWorkspace
|
isRemoteWorkspace: isRemoteWorkspace,
|
||||||
|
remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil
|
||||||
)
|
)
|
||||||
panels[browserPanel.id] = browserPanel
|
panels[browserPanel.id] = browserPanel
|
||||||
panelTitles[browserPanel.id] = browserPanel.displayTitle
|
panelTitles[browserPanel.id] = browserPanel.displayTitle
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,19 @@ final class GhosttyConfigTests: XCTestCase {
|
||||||
let blue: Int
|
let blue: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func writeAppSupportConfig(
|
||||||
|
root: URL,
|
||||||
|
bundleIdentifier: String,
|
||||||
|
name: String = "config",
|
||||||
|
contents: String = "font-size = 14\n"
|
||||||
|
) throws -> URL {
|
||||||
|
let directory = root.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
let url = directory.appendingPathComponent(name, isDirectory: false)
|
||||||
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
func testResolveThemeNamePrefersLightEntryForPairedTheme() {
|
func testResolveThemeNamePrefersLightEntryForPairedTheme() {
|
||||||
let resolved = GhosttyConfig.resolveThemeName(
|
let resolved = GhosttyConfig.resolveThemeName(
|
||||||
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
|
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
|
||||||
|
|
@ -312,48 +325,69 @@ final class GhosttyConfigTests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() {
|
func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() {
|
||||||
XCTAssertTrue(
|
let root = FileManager.default.temporaryDirectory
|
||||||
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
|
||||||
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
currentConfigFileSize: nil,
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
currentLegacyConfigFileSize: nil,
|
|
||||||
releaseConfigFileSize: 128,
|
let releaseURL = try? writeAppSupportConfig(
|
||||||
releaseLegacyConfigFileSize: nil
|
root: root,
|
||||||
|
bundleIdentifier: "com.cmuxterm.app"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||||
|
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
||||||
|
appSupportDirectory: root
|
||||||
|
),
|
||||||
|
[releaseURL].compactMap { $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() {
|
func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() {
|
||||||
XCTAssertFalse(
|
let root = FileManager.default.temporaryDirectory
|
||||||
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
|
||||||
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
|
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
currentConfigFileSize: nil,
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
currentLegacyConfigFileSize: 64,
|
|
||||||
releaseConfigFileSize: 128,
|
_ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app")
|
||||||
releaseLegacyConfigFileSize: nil
|
let debugURL = try? writeAppSupportConfig(
|
||||||
|
root: root,
|
||||||
|
bundleIdentifier: "com.cmuxterm.app.debug.issue-829",
|
||||||
|
name: "config.ghostty"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||||
|
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
|
||||||
|
appSupportDirectory: root
|
||||||
|
),
|
||||||
|
[debugURL].compactMap { $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() {
|
func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() {
|
||||||
XCTAssertFalse(
|
let root = FileManager.default.temporaryDirectory
|
||||||
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
|
||||||
|
_ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app")
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||||
currentBundleIdentifier: "com.cmuxterm.app",
|
currentBundleIdentifier: "com.cmuxterm.app",
|
||||||
currentConfigFileSize: nil,
|
appSupportDirectory: root
|
||||||
currentLegacyConfigFileSize: nil,
|
).count,
|
||||||
releaseConfigFileSize: 128,
|
1
|
||||||
releaseLegacyConfigFileSize: nil
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertFalse(
|
XCTAssertEqual(
|
||||||
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||||
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
||||||
currentConfigFileSize: nil,
|
appSupportDirectory: root.appendingPathComponent("missing", isDirectory: true)
|
||||||
currentLegacyConfigFileSize: nil,
|
),
|
||||||
releaseConfigFileSize: nil,
|
[]
|
||||||
releaseLegacyConfigFileSize: 0
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -831,12 +865,79 @@ final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class BrowserPanelRemoteStoreTests: XCTestCase {
|
final class BrowserPanelRemoteStoreTests: XCTestCase {
|
||||||
func testRemoteWorkspaceUsesDedicatedWebsiteDataStore() {
|
func testRemoteWorkspacePanelsShareWorkspaceScopedWebsiteDataStore() {
|
||||||
let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false)
|
let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false)
|
||||||
let remotePanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: true)
|
let remoteWorkspaceId = UUID()
|
||||||
|
let firstRemotePanel = BrowserPanel(
|
||||||
|
workspaceId: remoteWorkspaceId,
|
||||||
|
isRemoteWorkspace: true,
|
||||||
|
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
|
||||||
|
)
|
||||||
|
let secondRemotePanel = BrowserPanel(
|
||||||
|
workspaceId: remoteWorkspaceId,
|
||||||
|
isRemoteWorkspace: true,
|
||||||
|
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
|
||||||
|
)
|
||||||
|
|
||||||
XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
|
XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
|
||||||
XCTAssertFalse(remotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
|
XCTAssertFalse(firstRemotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
|
||||||
|
XCTAssertTrue(
|
||||||
|
firstRemotePanel.webView.configuration.websiteDataStore ===
|
||||||
|
secondRemotePanel.webView.configuration.websiteDataStore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemoteWorkspaceDefersInitialNavigationUntilProxyEndpointIsReady() {
|
||||||
|
let remoteWorkspaceId = UUID()
|
||||||
|
let url = URL(string: "http://localhost:3000/demo")!
|
||||||
|
let panel = BrowserPanel(
|
||||||
|
workspaceId: remoteWorkspaceId,
|
||||||
|
initialURL: url,
|
||||||
|
isRemoteWorkspace: true,
|
||||||
|
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
|
||||||
|
XCTAssertNil(panel.webView.url)
|
||||||
|
|
||||||
|
panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876))
|
||||||
|
|
||||||
|
let deadline = Date().addingTimeInterval(1.0)
|
||||||
|
while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {}
|
||||||
|
|
||||||
|
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
|
||||||
|
XCTAssertEqual(panel.webView.url?.host, "cmux-loopback.localtest.me")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewTerminalSurfaceStaysRemoteWhileBrowserPanelsKeepWorkspaceRemote() throws {
|
||||||
|
let workspace = Workspace()
|
||||||
|
let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first)
|
||||||
|
let initialTerminalId = try XCTUnwrap(workspace.focusedPanelId)
|
||||||
|
let configuration = WorkspaceRemoteConfiguration(
|
||||||
|
destination: "cmux-macmini",
|
||||||
|
port: nil,
|
||||||
|
identityFile: nil,
|
||||||
|
sshOptions: [],
|
||||||
|
localProxyPort: nil,
|
||||||
|
relayPort: 64000,
|
||||||
|
relayID: "relay-test",
|
||||||
|
relayToken: String(repeating: "a", count: 64),
|
||||||
|
localSocketPath: "/tmp/cmux-test.sock",
|
||||||
|
terminalStartupCommand: "ssh cmux-macmini"
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace.configureRemoteConnection(configuration, autoConnect: false)
|
||||||
|
_ = workspace.newBrowserSurface(inPane: paneId, url: URL(string: "https://example.com"), focus: false)
|
||||||
|
|
||||||
|
workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalId, relayPort: configuration.relayPort)
|
||||||
|
|
||||||
|
XCTAssertTrue(workspace.isRemoteWorkspace)
|
||||||
|
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0)
|
||||||
|
|
||||||
|
_ = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false))
|
||||||
|
|
||||||
|
XCTAssertTrue(workspace.isRemoteWorkspace)
|
||||||
|
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,4 +46,30 @@ final class TabManagerSessionSnapshotTests: XCTestCase {
|
||||||
XCTAssertEqual(manager.tabs.count, 1)
|
XCTAssertEqual(manager.tabs.count, 1)
|
||||||
XCTAssertNotNil(manager.selectedTabId)
|
XCTAssertNotNil(manager.selectedTabId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSessionSnapshotExcludesRemoteWorkspacesFromRestore() throws {
|
||||||
|
let manager = TabManager()
|
||||||
|
let remoteWorkspace = manager.addWorkspace(select: true)
|
||||||
|
let configuration = WorkspaceRemoteConfiguration(
|
||||||
|
destination: "cmux-macmini",
|
||||||
|
port: nil,
|
||||||
|
identityFile: nil,
|
||||||
|
sshOptions: [],
|
||||||
|
localProxyPort: nil,
|
||||||
|
relayPort: 64001,
|
||||||
|
relayID: "relay-test",
|
||||||
|
relayToken: String(repeating: "b", count: 64),
|
||||||
|
localSocketPath: "/tmp/cmux-test.sock",
|
||||||
|
terminalStartupCommand: "ssh cmux-macmini"
|
||||||
|
)
|
||||||
|
remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false)
|
||||||
|
let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first)
|
||||||
|
_ = remoteWorkspace.newBrowserSurface(inPane: paneId, url: URL(string: "http://localhost:3000"), focus: false)
|
||||||
|
|
||||||
|
let snapshot = manager.sessionSnapshot(includeScrollback: false)
|
||||||
|
|
||||||
|
XCTAssertEqual(snapshot.workspaces.count, 1)
|
||||||
|
XCTAssertNil(snapshot.selectedWorkspaceIndex)
|
||||||
|
XCTAssertFalse(snapshot.workspaces.contains { $0.processTitle == remoteWorkspace.title })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
tests_v2/test_workspace_create_background_starts_terminal.py
Normal file
83
tests_v2/test_workspace_create_background_starts_terminal.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression: background workspace.create should start its initial terminal before selection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from cmux import cmux, cmuxError
|
||||||
|
|
||||||
|
|
||||||
|
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||||
|
|
||||||
|
|
||||||
|
def _must(cond: bool, msg: str) -> None:
|
||||||
|
if not cond:
|
||||||
|
raise cmuxError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_file_text(path: Path, needle: str, timeout_s: float = 8.0) -> str:
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
last_text = ""
|
||||||
|
while time.time() < deadline:
|
||||||
|
if path.exists():
|
||||||
|
last_text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
if needle in last_text:
|
||||||
|
return last_text
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise cmuxError(f"Timed out waiting for {needle!r} in background workspace file: {last_text!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with cmux(SOCKET_PATH) as c:
|
||||||
|
baseline_workspace = c.current_workspace()
|
||||||
|
created_workspace = ""
|
||||||
|
marker_path = Path(tempfile.gettempdir()) / f"cmux-bg-start-{int(time.time() * 1000)}.txt"
|
||||||
|
try:
|
||||||
|
token = f"CMUX_BG_START_{int(time.time() * 1000)}"
|
||||||
|
initial_command = (
|
||||||
|
"python3 -c " +
|
||||||
|
shlex.quote(
|
||||||
|
f"from pathlib import Path; Path({marker_path.as_posix()!r}).write_text({token!r}, encoding='utf-8')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
payload = c._call(
|
||||||
|
"workspace.create",
|
||||||
|
{"initial_command": initial_command},
|
||||||
|
) or {}
|
||||||
|
created_workspace = str(payload.get("workspace_id") or "")
|
||||||
|
_must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}")
|
||||||
|
_must(
|
||||||
|
c.current_workspace() == baseline_workspace,
|
||||||
|
"workspace.create should preserve selected workspace",
|
||||||
|
)
|
||||||
|
|
||||||
|
text = _wait_for_file_text(marker_path, token)
|
||||||
|
_must(token in text, f"Background workspace did not run its initial command: {text!r}")
|
||||||
|
_must(
|
||||||
|
c.current_workspace() == baseline_workspace,
|
||||||
|
"background eager load should not switch the selected workspace",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
marker_path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
if created_workspace:
|
||||||
|
try:
|
||||||
|
c.close_workspace(created_workspace)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("PASS: workspace.create eager background load starts the initial terminal without focus")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue