Skip to content

Commit d0fe6fa

Browse files
authored
fix: persist VS Code serve-web data dir and connection token across restarts (#21) (#42)
1 parent 65a3820 commit d0fe6fa

2 files changed

Lines changed: 92 additions & 8 deletions

File tree

Sources/AppDelegate.swift

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@ enum VSCodeCLILaunchConfigurationBuilder {
754754
}
755755
environment.removeValue(forKey: "NODE_OPTIONS")
756756
environment.removeValue(forKey: "NODE_REPL_EXTERNAL_MODULE")
757+
environment["VSCODE_CLI_USE_FILE_KEYRING"] = "1"
757758

758759
return VSCodeCLILaunchConfiguration(
759760
executableURL: codeTunnelURL,
@@ -928,7 +929,7 @@ final class VSCodeServeWebController {
928929
return (processes, tokenFileURLs, completions)
929930
}
930931

931-
for tokenFileURL in tokenFileURLs {
932+
for tokenFileURL in tokenFileURLs where tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) {
932933
Self.removeConnectionTokenFile(at: tokenFileURL)
933934
}
934935

@@ -966,11 +967,17 @@ final class VSCodeServeWebController {
966967

967968
let process = Process()
968969
process.executableURL = launchConfiguration.executableURL
970+
// TODO(#21): --port 0 means VS Code gets an OS-assigned ephemeral port each restart, so
971+
// the browser must re-navigate to the new URL. To stabilise: either use a fixed loopback
972+
// port, or persist the port assigned by VS Code (from ServeWebOutputCollector) under
973+
// vscodeServerDataDir and reuse it here. The --server-data-dir and persistent
974+
// connection-token already fix Settings Sync / OAuth auth; the URL change is UX-only.
969975
process.arguments = launchConfiguration.argumentsPrefix + [
970976
"serve-web",
971977
"--accept-server-license-terms",
972978
"--host", "127.0.0.1",
973979
"--port", "0",
980+
"--server-data-dir", Self.vscodeServerDataDir?.path ?? NSTemporaryDirectory(),
974981
"--connection-token-file", connectionTokenFileURL.path,
975982
]
976983
process.environment = launchConfiguration.environment
@@ -1006,7 +1013,7 @@ final class VSCodeServeWebController {
10061013
}
10071014
if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue(
10081015
forKey: ObjectIdentifier(terminatedProcess)
1009-
) {
1016+
), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) {
10101017
Self.removeConnectionTokenFile(at: tokenFileURL)
10111018
}
10121019
}
@@ -1028,7 +1035,7 @@ final class VSCodeServeWebController {
10281035
}
10291036
if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue(
10301037
forKey: ObjectIdentifier(process)
1031-
) {
1038+
), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) {
10321039
Self.removeConnectionTokenFile(at: tokenFileURL)
10331040
}
10341041
return false
@@ -1037,7 +1044,9 @@ final class VSCodeServeWebController {
10371044
guard didStart else {
10381045
stdoutPipe.fileHandleForReading.readabilityHandler = nil
10391046
stderrPipe.fileHandleForReading.readabilityHandler = nil
1040-
Self.removeConnectionTokenFile(at: connectionTokenFileURL)
1047+
if connectionTokenFileURL.path.hasPrefix(NSTemporaryDirectory()) {
1048+
Self.removeConnectionTokenFile(at: connectionTokenFileURL)
1049+
}
10411050
return nil
10421051
}
10431052

@@ -1058,7 +1067,7 @@ final class VSCodeServeWebController {
10581067
}
10591068
if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue(
10601069
forKey: ObjectIdentifier(process)
1061-
) {
1070+
), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) {
10621071
Self.removeConnectionTokenFile(at: tokenFileURL)
10631072
}
10641073
}
@@ -1077,21 +1086,65 @@ final class VSCodeServeWebController {
10771086
}
10781087
}
10791088

1089+
/// Stable Application Support directory for VS Code serve-web state.
1090+
/// Mirrors the "programa" subdirectory convention used elsewhere in the app.
1091+
private static var vscodeServerDataDir: URL? {
1092+
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
1093+
.first?
1094+
.appendingPathComponent("programa", isDirectory: true)
1095+
.appendingPathComponent("vscode-server", isDirectory: true)
1096+
}
1097+
10801098
private static func randomConnectionToken() -> String {
10811099
UUID().uuidString.replacingOccurrences(of: "-", with: "")
10821100
}
10831101

1102+
/// Returns a URL for the connection token file. Prefers a persistent file
1103+
/// under Application Support so the token (and the browser's vscode-tkn
1104+
/// cookie) survives Programa restarts (issue #21). Falls back to an
1105+
/// ephemeral temp-dir file when Application Support is unavailable.
10841106
private static func makeConnectionTokenFile() -> URL? {
1107+
if let persistentURL = vscodeServerDataDir?
1108+
.appendingPathComponent("connection-token", isDirectory: false) {
1109+
if let url = makePersistentConnectionTokenFile(at: persistentURL) {
1110+
return url
1111+
}
1112+
}
1113+
return makeEphemeralConnectionTokenFile()
1114+
}
1115+
1116+
private static func makePersistentConnectionTokenFile(at tokenFileURL: URL) -> URL? {
1117+
let dir = tokenFileURL.deletingLastPathComponent()
1118+
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1119+
// Reuse an existing non-empty token so the browser cookie stays valid.
1120+
if let existingData = try? Data(contentsOf: tokenFileURL), !existingData.isEmpty {
1121+
return tokenFileURL
1122+
}
1123+
let token = randomConnectionToken()
1124+
guard let tokenData = token.data(using: .utf8) else { return nil }
1125+
let fd = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR)
1126+
guard fd >= 0 else { return nil }
1127+
defer { _ = close(fd) }
1128+
let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in
1129+
guard let baseAddress = rawBuffer.baseAddress else { return false }
1130+
return write(fd, baseAddress, rawBuffer.count) == rawBuffer.count
1131+
}
1132+
guard wroteAllBytes else {
1133+
try? FileManager.default.removeItem(at: tokenFileURL)
1134+
return nil
1135+
}
1136+
return tokenFileURL
1137+
}
1138+
1139+
private static func makeEphemeralConnectionTokenFile() -> URL? {
10851140
let token = randomConnectionToken()
10861141
let tokenFileName = "cmux-vscode-token-\(UUID().uuidString)"
10871142
let tokenFileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
10881143
.appendingPathComponent(tokenFileName, isDirectory: false)
10891144
guard let tokenData = token.data(using: .utf8) else { return nil }
1090-
10911145
let fileDescriptor = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR)
10921146
guard fileDescriptor >= 0 else { return nil }
10931147
defer { _ = close(fileDescriptor) }
1094-
10951148
let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in
10961149
guard let baseAddress = rawBuffer.baseAddress else { return false }
10971150
return write(fileDescriptor, baseAddress, rawBuffer.count) == rawBuffer.count
@@ -1100,7 +1153,6 @@ final class VSCodeServeWebController {
11001153
removeConnectionTokenFile(at: tokenFileURL)
11011154
return nil
11021155
}
1103-
11041156
return tokenFileURL
11051157
}
11061158

cmuxTests/OmnibarAndToolsTests.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,38 @@ final class VSCodeServeWebControllerTests: XCTestCase {
379379

380380
XCTAssertFalse(FileManager.default.fileExists(atPath: tokenFileURL.path))
381381
}
382+
383+
func testStopDoesNotRemovePersistentConnectionTokenFile() throws {
384+
// Persistent token files live under Application Support, not NSTemporaryDirectory.
385+
// stop() must leave them intact so Settings Sync / auth survives restarts (issue #21).
386+
let appSupportDir = try XCTUnwrap(
387+
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first,
388+
"Application Support directory must be resolvable"
389+
)
390+
let persistentTokenDir = appSupportDir
391+
.appendingPathComponent("programa", isDirectory: true)
392+
.appendingPathComponent("vscode-server", isDirectory: true)
393+
try FileManager.default.createDirectory(at: persistentTokenDir, withIntermediateDirectories: true)
394+
// Use a unique file name to avoid interfering with the real connection-token.
395+
let tokenFileURL = persistentTokenDir
396+
.appendingPathComponent("connection-token-test-\(UUID().uuidString)", isDirectory: false)
397+
defer { try? FileManager.default.removeItem(at: tokenFileURL) }
398+
try Data("persistenttoken".utf8).write(to: tokenFileURL)
399+
XCTAssertTrue(FileManager.default.fileExists(atPath: tokenFileURL.path))
400+
401+
let controller = VSCodeServeWebController.makeForTesting { _, _ in
402+
XCTFail("Expected no launch")
403+
return nil
404+
}
405+
controller.trackConnectionTokenFileForTesting(tokenFileURL)
406+
407+
controller.stop()
408+
409+
XCTAssertTrue(
410+
FileManager.default.fileExists(atPath: tokenFileURL.path),
411+
"Persistent token under Application Support must not be deleted by stop()"
412+
)
413+
}
382414
}
383415

384416

0 commit comments

Comments
 (0)