@wangyaoshen/remux 0.3.8-dev.29e114b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- package/vitest.config.js +10 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AppKit
|
|
3
|
+
|
|
4
|
+
/// Detects SSH connection patterns in terminal text.
|
|
5
|
+
/// When detected: enables "Upload File" button in toolbar.
|
|
6
|
+
/// Upload uses a command sent to the terminal (scp local remote).
|
|
7
|
+
///
|
|
8
|
+
/// Adapted from Warp SSH detection / Termius file transfer UX.
|
|
9
|
+
@MainActor
|
|
10
|
+
@Observable
|
|
11
|
+
final class SSHDetector {
|
|
12
|
+
|
|
13
|
+
/// Detected SSH connection info.
|
|
14
|
+
struct SSHConnection: Identifiable, Equatable, Sendable {
|
|
15
|
+
let id: UUID
|
|
16
|
+
let user: String
|
|
17
|
+
let host: String
|
|
18
|
+
let port: Int?
|
|
19
|
+
let detectedAt: Date
|
|
20
|
+
|
|
21
|
+
/// SCP destination prefix, e.g. "user@host:"
|
|
22
|
+
var scpPrefix: String {
|
|
23
|
+
if let port {
|
|
24
|
+
return "-P \(port) \(user)@\(host):"
|
|
25
|
+
}
|
|
26
|
+
return "\(user)@\(host):"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var displayString: String {
|
|
30
|
+
if let port {
|
|
31
|
+
return "\(user)@\(host):\(port)"
|
|
32
|
+
}
|
|
33
|
+
return "\(user)@\(host)"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
init(user: String, host: String, port: Int? = nil) {
|
|
37
|
+
self.id = UUID()
|
|
38
|
+
self.user = user
|
|
39
|
+
self.host = host
|
|
40
|
+
self.port = port
|
|
41
|
+
self.detectedAt = Date()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Currently detected SSH connections.
|
|
46
|
+
private(set) var connections: [SSHConnection] = []
|
|
47
|
+
|
|
48
|
+
/// Whether any SSH connection is active.
|
|
49
|
+
var hasActiveConnection: Bool {
|
|
50
|
+
!connections.isEmpty
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// The most recent SSH connection.
|
|
54
|
+
var latestConnection: SSHConnection? {
|
|
55
|
+
connections.last
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// MARK: - Detection patterns
|
|
59
|
+
|
|
60
|
+
/// SSH command patterns to detect.
|
|
61
|
+
/// Matches: ssh user@host, ssh -p port user@host, ssh host
|
|
62
|
+
private static let sshPatterns: [(pattern: String, userGroup: Int, hostGroup: Int, portGroup: Int?)] = [
|
|
63
|
+
// ssh -p PORT user@host
|
|
64
|
+
("ssh\\s+-p\\s+(\\d+)\\s+(\\w+)@([\\w\\.-]+)", 2, 3, 1),
|
|
65
|
+
// ssh user@host -p PORT
|
|
66
|
+
("ssh\\s+(\\w+)@([\\w\\.-]+)\\s+-p\\s+(\\d+)", 1, 2, 3),
|
|
67
|
+
// ssh user@host
|
|
68
|
+
("ssh\\s+(\\w+)@([\\w\\.-]+)", 1, 2, nil),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
/// SCP command patterns.
|
|
72
|
+
private static let scpPatterns: [String] = [
|
|
73
|
+
"scp\\s+.*?(\\w+)@([\\w\\.-]+):",
|
|
74
|
+
"scp\\s+-P\\s+(\\d+)\\s+.*?(\\w+)@([\\w\\.-]+):",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
// MARK: - Parse terminal text
|
|
78
|
+
|
|
79
|
+
/// Parse terminal text for SSH connection patterns.
|
|
80
|
+
func parseTerminalOutput(_ text: String) {
|
|
81
|
+
for (pattern, userGroup, hostGroup, portGroup) in Self.sshPatterns {
|
|
82
|
+
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
|
|
83
|
+
let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
|
|
84
|
+
|
|
85
|
+
for match in matches {
|
|
86
|
+
guard let userRange = Range(match.range(at: userGroup), in: text),
|
|
87
|
+
let hostRange = Range(match.range(at: hostGroup), in: text) else {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let user = String(text[userRange])
|
|
92
|
+
let host = String(text[hostRange])
|
|
93
|
+
|
|
94
|
+
var port: Int?
|
|
95
|
+
if let pg = portGroup, let portRange = Range(match.range(at: pg), in: text) {
|
|
96
|
+
port = Int(text[portRange])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
addConnection(user: user, host: host, port: port)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Add a connection if not already tracked.
|
|
105
|
+
private func addConnection(user: String, host: String, port: Int?) {
|
|
106
|
+
let exists = connections.contains { c in
|
|
107
|
+
c.user == user && c.host == host && c.port == port
|
|
108
|
+
}
|
|
109
|
+
guard !exists else { return }
|
|
110
|
+
|
|
111
|
+
let conn = SSHConnection(user: user, host: host, port: port)
|
|
112
|
+
connections.append(conn)
|
|
113
|
+
NSLog("[remux] SSH detected: %@", conn.displayString)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Clear all detected connections.
|
|
117
|
+
func clearAll() {
|
|
118
|
+
connections.removeAll()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Remove a specific connection.
|
|
122
|
+
func remove(_ connection: SSHConnection) {
|
|
123
|
+
connections.removeAll { $0.id == connection.id }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// MARK: - File upload via SCP
|
|
127
|
+
|
|
128
|
+
/// Build an SCP command to upload a local file to the remote host.
|
|
129
|
+
/// Returns the command string to be typed into the terminal.
|
|
130
|
+
func buildUploadCommand(localPath: String, remotePath: String = "~", connection: SSHConnection? = nil) -> String? {
|
|
131
|
+
guard let conn = connection ?? latestConnection else { return nil }
|
|
132
|
+
|
|
133
|
+
let escapedLocal = localPath.replacingOccurrences(of: " ", with: "\\ ")
|
|
134
|
+
|
|
135
|
+
if let port = conn.port {
|
|
136
|
+
return "scp -P \(port) \(escapedLocal) \(conn.user)@\(conn.host):\(remotePath)"
|
|
137
|
+
}
|
|
138
|
+
return "scp \(escapedLocal) \(conn.user)@\(conn.host):\(remotePath)"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Show file picker and return selected file path.
|
|
142
|
+
func pickFileForUpload() -> URL? {
|
|
143
|
+
let panel = NSOpenPanel()
|
|
144
|
+
panel.allowsMultipleSelection = false
|
|
145
|
+
panel.canChooseDirectories = false
|
|
146
|
+
panel.canChooseFiles = true
|
|
147
|
+
|
|
148
|
+
guard panel.runModal() == .OK else { return nil }
|
|
149
|
+
return panel.url
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - Data Models
|
|
4
|
+
|
|
5
|
+
/// Snapshot of the entire app session for persistence.
|
|
6
|
+
struct AppSession: Codable, Sendable {
|
|
7
|
+
var version: Int = 1
|
|
8
|
+
var serverURL: String?
|
|
9
|
+
var windowFrame: CodableRect?
|
|
10
|
+
var splitLayout: SplitNodeSnapshot
|
|
11
|
+
var sidebarCollapsed: Bool
|
|
12
|
+
|
|
13
|
+
init(
|
|
14
|
+
serverURL: String? = nil,
|
|
15
|
+
windowFrame: CGRect? = nil,
|
|
16
|
+
splitLayout: SplitNodeSnapshot = .leaf(tabIndex: 0),
|
|
17
|
+
sidebarCollapsed: Bool = false
|
|
18
|
+
) {
|
|
19
|
+
self.serverURL = serverURL
|
|
20
|
+
self.windowFrame = windowFrame.map { CodableRect(rect: $0) }
|
|
21
|
+
self.splitLayout = splitLayout
|
|
22
|
+
self.sidebarCollapsed = sidebarCollapsed
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
var windowCGRect: CGRect? {
|
|
26
|
+
windowFrame?.cgRect
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Codable wrapper for CGRect since CGRect's Codable is not reliable across platforms.
|
|
31
|
+
struct CodableRect: Codable, Sendable {
|
|
32
|
+
var x: Double
|
|
33
|
+
var y: Double
|
|
34
|
+
var width: Double
|
|
35
|
+
var height: Double
|
|
36
|
+
|
|
37
|
+
init(rect: CGRect) {
|
|
38
|
+
self.x = rect.origin.x
|
|
39
|
+
self.y = rect.origin.y
|
|
40
|
+
self.width = rect.size.width
|
|
41
|
+
self.height = rect.size.height
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var cgRect: CGRect {
|
|
45
|
+
CGRect(x: x, y: y, width: width, height: height)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Serializable snapshot of the split tree layout.
|
|
50
|
+
indirect enum SplitNodeSnapshot: Codable, Sendable, Hashable {
|
|
51
|
+
case leaf(tabIndex: Int)
|
|
52
|
+
case branch(orientation: String, ratio: Double, first: SplitNodeSnapshot, second: SplitNodeSnapshot)
|
|
53
|
+
|
|
54
|
+
// Custom Codable implementation for indirect enum
|
|
55
|
+
private enum CodingKeys: String, CodingKey {
|
|
56
|
+
case type, tabIndex, orientation, ratio, first, second
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func encode(to encoder: Encoder) throws {
|
|
60
|
+
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
61
|
+
switch self {
|
|
62
|
+
case .leaf(let tabIndex):
|
|
63
|
+
try container.encode("leaf", forKey: .type)
|
|
64
|
+
try container.encode(tabIndex, forKey: .tabIndex)
|
|
65
|
+
case .branch(let orientation, let ratio, let first, let second):
|
|
66
|
+
try container.encode("branch", forKey: .type)
|
|
67
|
+
try container.encode(orientation, forKey: .orientation)
|
|
68
|
+
try container.encode(ratio, forKey: .ratio)
|
|
69
|
+
try container.encode(first, forKey: .first)
|
|
70
|
+
try container.encode(second, forKey: .second)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
init(from decoder: Decoder) throws {
|
|
75
|
+
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
76
|
+
let type = try container.decode(String.self, forKey: .type)
|
|
77
|
+
switch type {
|
|
78
|
+
case "leaf":
|
|
79
|
+
let tabIndex = try container.decode(Int.self, forKey: .tabIndex)
|
|
80
|
+
self = .leaf(tabIndex: tabIndex)
|
|
81
|
+
case "branch":
|
|
82
|
+
let orientation = try container.decode(String.self, forKey: .orientation)
|
|
83
|
+
let ratio = try container.decode(Double.self, forKey: .ratio)
|
|
84
|
+
let first = try container.decode(SplitNodeSnapshot.self, forKey: .first)
|
|
85
|
+
let second = try container.decode(SplitNodeSnapshot.self, forKey: .second)
|
|
86
|
+
self = .branch(orientation: orientation, ratio: ratio, first: first, second: second)
|
|
87
|
+
default:
|
|
88
|
+
throw DecodingError.dataCorruptedError(
|
|
89
|
+
forKey: .type,
|
|
90
|
+
in: container,
|
|
91
|
+
debugDescription: "Unknown SplitNodeSnapshot type: \(type)"
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// MARK: - SplitNode <-> Snapshot Conversion
|
|
98
|
+
|
|
99
|
+
extension SplitNode {
|
|
100
|
+
/// Convert a live SplitNode tree to a serializable snapshot.
|
|
101
|
+
func toSnapshot() -> SplitNodeSnapshot {
|
|
102
|
+
switch self {
|
|
103
|
+
case .leaf(let data):
|
|
104
|
+
return .leaf(tabIndex: data.tabIndex)
|
|
105
|
+
case .branch(let data):
|
|
106
|
+
return .branch(
|
|
107
|
+
orientation: data.orientation.rawValue,
|
|
108
|
+
ratio: Double(data.ratio),
|
|
109
|
+
first: data.first.toSnapshot(),
|
|
110
|
+
second: data.second.toSnapshot()
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Reconstruct a SplitNode tree from a snapshot.
|
|
116
|
+
static func fromSnapshot(_ snapshot: SplitNodeSnapshot) -> SplitNode {
|
|
117
|
+
switch snapshot {
|
|
118
|
+
case .leaf(let tabIndex):
|
|
119
|
+
return .leaf(LeafData(tabIndex: tabIndex))
|
|
120
|
+
case .branch(let orientation, let ratio, let first, let second):
|
|
121
|
+
let orient: Orientation = orientation == "horizontal" ? .horizontal : .vertical
|
|
122
|
+
return .branch(BranchData(
|
|
123
|
+
orientation: orient,
|
|
124
|
+
ratio: CGFloat(ratio),
|
|
125
|
+
first: fromSnapshot(first),
|
|
126
|
+
second: fromSnapshot(second)
|
|
127
|
+
))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// MARK: - Persistence Manager
|
|
133
|
+
|
|
134
|
+
/// Manages reading and writing the app session to disk.
|
|
135
|
+
/// File location: ~/Library/Application Support/com.remux/session.json
|
|
136
|
+
final class SessionPersistence: @unchecked Sendable {
|
|
137
|
+
|
|
138
|
+
static let shared = SessionPersistence()
|
|
139
|
+
|
|
140
|
+
private let fileManager = FileManager.default
|
|
141
|
+
nonisolated(unsafe) private var autosaveTimer: Timer?
|
|
142
|
+
nonisolated(unsafe) private var lastSavedHash: Int = 0
|
|
143
|
+
|
|
144
|
+
private var sessionDirectory: URL {
|
|
145
|
+
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
146
|
+
return appSupport.appendingPathComponent("com.remux", isDirectory: true)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private var sessionFilePath: URL {
|
|
150
|
+
sessionDirectory.appendingPathComponent("session.json")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private init() {}
|
|
154
|
+
|
|
155
|
+
// MARK: - Save
|
|
156
|
+
|
|
157
|
+
/// Save the app session to disk.
|
|
158
|
+
@MainActor
|
|
159
|
+
func save(_ session: AppSession) {
|
|
160
|
+
do {
|
|
161
|
+
// Ensure directory exists
|
|
162
|
+
try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true)
|
|
163
|
+
|
|
164
|
+
let encoder = JSONEncoder()
|
|
165
|
+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
166
|
+
let data = try encoder.encode(session)
|
|
167
|
+
|
|
168
|
+
// Skip write if unchanged
|
|
169
|
+
let hash = data.hashValue
|
|
170
|
+
if hash == lastSavedHash { return }
|
|
171
|
+
lastSavedHash = hash
|
|
172
|
+
|
|
173
|
+
try data.write(to: sessionFilePath, options: .atomic)
|
|
174
|
+
NSLog("[remux] Session saved to %@", sessionFilePath.path)
|
|
175
|
+
} catch {
|
|
176
|
+
NSLog("[remux] Failed to save session: %@", error.localizedDescription)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// MARK: - Load
|
|
181
|
+
|
|
182
|
+
/// Load the app session from disk. Returns nil if no saved session exists.
|
|
183
|
+
@MainActor
|
|
184
|
+
func load() -> AppSession? {
|
|
185
|
+
guard fileManager.fileExists(atPath: sessionFilePath.path) else {
|
|
186
|
+
NSLog("[remux] No saved session found at %@", sessionFilePath.path)
|
|
187
|
+
return nil
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
do {
|
|
191
|
+
let data = try Data(contentsOf: sessionFilePath)
|
|
192
|
+
let decoder = JSONDecoder()
|
|
193
|
+
let session = try decoder.decode(AppSession.self, from: data)
|
|
194
|
+
lastSavedHash = data.hashValue
|
|
195
|
+
NSLog("[remux] Session loaded from %@", sessionFilePath.path)
|
|
196
|
+
return session
|
|
197
|
+
} catch {
|
|
198
|
+
NSLog("[remux] Failed to load session: %@", error.localizedDescription)
|
|
199
|
+
return nil
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// MARK: - Autosave
|
|
204
|
+
|
|
205
|
+
/// Start the autosave timer. The closure is called every 8 seconds to
|
|
206
|
+
/// get the current session state.
|
|
207
|
+
@MainActor
|
|
208
|
+
func startAutosave(sessionProvider: @escaping @MainActor () -> AppSession) {
|
|
209
|
+
stopAutosave()
|
|
210
|
+
autosaveTimer = Timer.scheduledTimer(withTimeInterval: 8.0, repeats: true) { [weak self] _ in
|
|
211
|
+
Task { @MainActor in
|
|
212
|
+
guard let self else { return }
|
|
213
|
+
let session = sessionProvider()
|
|
214
|
+
self.save(session)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
NSLog("[remux] Autosave started (8s interval)")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Stop the autosave timer.
|
|
221
|
+
@MainActor
|
|
222
|
+
func stopAutosave() {
|
|
223
|
+
autosaveTimer?.invalidate()
|
|
224
|
+
autosaveTimer = nil
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// Unix socket control API for scriptable automation.
|
|
5
|
+
/// Listens on ~/Library/Application Support/com.remux/remux.sock
|
|
6
|
+
/// Protocol: JSON-RPC — request: {"method": "...", "params": {...}, "id": 1}
|
|
7
|
+
/// response: {"result": {...}, "id": 1} or {"error": "...", "id": 1}
|
|
8
|
+
///
|
|
9
|
+
/// Supported methods: list_tabs, create_tab, close_tab, write_input,
|
|
10
|
+
/// get_state, list_sessions, switch_tab
|
|
11
|
+
///
|
|
12
|
+
/// Adapted from tmux control-mode / neovim --listen socket API patterns.
|
|
13
|
+
final class SocketController: @unchecked Sendable {
|
|
14
|
+
|
|
15
|
+
private let socketPath: String
|
|
16
|
+
private var serverFD: Int32 = -1
|
|
17
|
+
private var isRunning = false
|
|
18
|
+
private let queue = DispatchQueue(label: "remux.socket-controller")
|
|
19
|
+
private weak var state: RemuxState?
|
|
20
|
+
|
|
21
|
+
init(state: RemuxState) {
|
|
22
|
+
self.state = state
|
|
23
|
+
|
|
24
|
+
let appSupport = FileManager.default.urls(
|
|
25
|
+
for: .applicationSupportDirectory, in: .userDomainMask
|
|
26
|
+
).first!
|
|
27
|
+
let dir = appSupport.appendingPathComponent("com.remux", isDirectory: true)
|
|
28
|
+
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
29
|
+
socketPath = dir.appendingPathComponent("remux.sock").path
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Start listening on the Unix socket.
|
|
33
|
+
func start() {
|
|
34
|
+
guard !isRunning else { return }
|
|
35
|
+
isRunning = true
|
|
36
|
+
|
|
37
|
+
// Remove stale socket
|
|
38
|
+
unlink(socketPath)
|
|
39
|
+
|
|
40
|
+
serverFD = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
41
|
+
guard serverFD >= 0 else {
|
|
42
|
+
NSLog("[remux] SocketController: failed to create socket")
|
|
43
|
+
isRunning = false
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
var addr = sockaddr_un()
|
|
48
|
+
addr.sun_family = sa_family_t(AF_UNIX)
|
|
49
|
+
let pathBytes = socketPath.utf8CString
|
|
50
|
+
pathBytes.withUnsafeBufferPointer { buf in
|
|
51
|
+
withUnsafeMutableBytes(of: &addr.sun_path) { rawPath in
|
|
52
|
+
let count = min(buf.count, rawPath.count)
|
|
53
|
+
rawPath.copyBytes(from: UnsafeRawBufferPointer(buf).prefix(count))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let bindResult = withUnsafePointer(to: &addr) { ptr in
|
|
58
|
+
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
|
|
59
|
+
Darwin.bind(serverFD, sockPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
guard bindResult == 0 else {
|
|
64
|
+
NSLog("[remux] SocketController: bind failed: %d", errno)
|
|
65
|
+
close(serverFD)
|
|
66
|
+
serverFD = -1
|
|
67
|
+
isRunning = false
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
listen(serverFD, 1)
|
|
72
|
+
NSLog("[remux] SocketController: listening on %@", socketPath)
|
|
73
|
+
|
|
74
|
+
queue.async { [weak self] in
|
|
75
|
+
self?.acceptLoop()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Stop the socket controller and clean up.
|
|
80
|
+
func stop() {
|
|
81
|
+
isRunning = false
|
|
82
|
+
if serverFD >= 0 {
|
|
83
|
+
close(serverFD)
|
|
84
|
+
serverFD = -1
|
|
85
|
+
}
|
|
86
|
+
unlink(socketPath)
|
|
87
|
+
NSLog("[remux] SocketController: stopped")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// MARK: - Accept loop
|
|
91
|
+
|
|
92
|
+
private func acceptLoop() {
|
|
93
|
+
while isRunning {
|
|
94
|
+
let clientFD = accept(serverFD, nil, nil)
|
|
95
|
+
guard clientFD >= 0 else {
|
|
96
|
+
if isRunning { NSLog("[remux] SocketController: accept failed") }
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
handleClient(fd: clientFD)
|
|
100
|
+
close(clientFD)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// MARK: - Client handling
|
|
105
|
+
|
|
106
|
+
/// Thread-safe result container using a class marked @unchecked Sendable.
|
|
107
|
+
private final class RPCResultBox: @unchecked Sendable {
|
|
108
|
+
var resultJSON: String?
|
|
109
|
+
var errorMsg: String?
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private func handleClient(fd: Int32) {
|
|
113
|
+
var buffer = [UInt8](repeating: 0, count: 65536)
|
|
114
|
+
let n = recv(fd, &buffer, buffer.count, 0)
|
|
115
|
+
guard n > 0 else { return }
|
|
116
|
+
|
|
117
|
+
let data = Data(buffer[0..<n])
|
|
118
|
+
guard let _ = String(data: data, encoding: .utf8),
|
|
119
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
120
|
+
sendResponse(fd: fd, error: "Invalid JSON", id: nil)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let method = json["method"] as? String ?? ""
|
|
125
|
+
let requestID = json["id"]
|
|
126
|
+
|
|
127
|
+
// Extract params as Sendable strings before crossing isolation boundary
|
|
128
|
+
let tabIDParam = (json["params"] as? [String: Any])?["tabId"] as? String
|
|
129
|
+
let textParam = (json["params"] as? [String: Any])?["text"] as? String
|
|
130
|
+
let indexParam = (json["params"] as? [String: Any])?["index"] as? Int
|
|
131
|
+
|
|
132
|
+
let sem = DispatchSemaphore(value: 0)
|
|
133
|
+
let box = RPCResultBox()
|
|
134
|
+
|
|
135
|
+
DispatchQueue.main.async { [weak self, box] in
|
|
136
|
+
defer { sem.signal() }
|
|
137
|
+
guard let self, let state = self.state else {
|
|
138
|
+
box.errorMsg = "State unavailable"
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
switch method {
|
|
143
|
+
case "list_tabs":
|
|
144
|
+
let tabs: [[String: Any]] = state.tabs.map { tab in
|
|
145
|
+
[
|
|
146
|
+
"index": tab.index,
|
|
147
|
+
"name": tab.name,
|
|
148
|
+
"active": tab.active,
|
|
149
|
+
"paneCount": tab.panes.count,
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
let dict: [String: Any] = ["tabs": tabs]
|
|
153
|
+
if let d = try? JSONSerialization.data(withJSONObject: dict),
|
|
154
|
+
let s = String(data: d, encoding: .utf8) {
|
|
155
|
+
box.resultJSON = s
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case "create_tab":
|
|
159
|
+
state.createTab()
|
|
160
|
+
box.resultJSON = "{\"ok\":true}"
|
|
161
|
+
|
|
162
|
+
case "close_tab":
|
|
163
|
+
if let tabID = tabIDParam {
|
|
164
|
+
state.closeTab(id: tabID)
|
|
165
|
+
box.resultJSON = "{\"ok\":true}"
|
|
166
|
+
} else {
|
|
167
|
+
box.errorMsg = "Missing tabId parameter"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case "write_input":
|
|
171
|
+
if let text = textParam {
|
|
172
|
+
state.sendTerminalInput(text)
|
|
173
|
+
box.resultJSON = "{\"ok\":true}"
|
|
174
|
+
} else {
|
|
175
|
+
box.errorMsg = "Missing text parameter"
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case "get_state":
|
|
179
|
+
let dict: [String: Any] = [
|
|
180
|
+
"session": state.currentSession,
|
|
181
|
+
"activeTabIndex": state.activeTabIndex,
|
|
182
|
+
"tabCount": state.tabs.count,
|
|
183
|
+
"role": state.clientRole,
|
|
184
|
+
"connected": state.connectionStatus == .connected,
|
|
185
|
+
]
|
|
186
|
+
if let d = try? JSONSerialization.data(withJSONObject: dict),
|
|
187
|
+
let s = String(data: d, encoding: .utf8) {
|
|
188
|
+
box.resultJSON = s
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case "list_sessions":
|
|
192
|
+
let dict: [String: Any] = ["session": state.currentSession]
|
|
193
|
+
if let d = try? JSONSerialization.data(withJSONObject: dict),
|
|
194
|
+
let s = String(data: d, encoding: .utf8) {
|
|
195
|
+
box.resultJSON = s
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case "switch_tab":
|
|
199
|
+
if let tabID = tabIDParam {
|
|
200
|
+
state.switchTab(id: tabID)
|
|
201
|
+
box.resultJSON = "{\"ok\":true}"
|
|
202
|
+
} else if let index = indexParam,
|
|
203
|
+
index < state.tabs.count,
|
|
204
|
+
let pane = state.tabs[index].panes.first {
|
|
205
|
+
state.switchTab(id: pane.id)
|
|
206
|
+
box.resultJSON = "{\"ok\":true}"
|
|
207
|
+
} else {
|
|
208
|
+
box.errorMsg = "Missing or invalid tabId/index parameter"
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
default:
|
|
212
|
+
box.errorMsg = "Unknown method: \(method)"
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
sem.wait()
|
|
217
|
+
|
|
218
|
+
if let error = box.errorMsg {
|
|
219
|
+
sendResponse(fd: fd, error: error, id: requestID)
|
|
220
|
+
} else {
|
|
221
|
+
sendResponseRaw(fd: fd, json: box.resultJSON ?? "{\"ok\":true}", id: requestID)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// MARK: - Response helpers
|
|
226
|
+
|
|
227
|
+
private func sendResponse(fd: Int32, error: String, id: Any?) {
|
|
228
|
+
var dict: [String: Any] = ["error": error]
|
|
229
|
+
if let id { dict["id"] = id }
|
|
230
|
+
guard let data = try? JSONSerialization.data(withJSONObject: dict),
|
|
231
|
+
var response = String(data: data, encoding: .utf8) else { return }
|
|
232
|
+
response += "\n"
|
|
233
|
+
response.withCString { ptr in
|
|
234
|
+
_ = send(fd, ptr, strlen(ptr), 0)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private func sendResponseRaw(fd: Int32, json: String, id: Any?) {
|
|
239
|
+
// Wrap the pre-serialized JSON result into a response envelope
|
|
240
|
+
var response: String
|
|
241
|
+
if let id {
|
|
242
|
+
let idStr: String
|
|
243
|
+
if let intID = id as? Int {
|
|
244
|
+
idStr = "\(intID)"
|
|
245
|
+
} else if let strID = id as? String {
|
|
246
|
+
idStr = "\"\(strID)\""
|
|
247
|
+
} else {
|
|
248
|
+
idStr = "null"
|
|
249
|
+
}
|
|
250
|
+
response = "{\"result\":\(json),\"id\":\(idStr)}\n"
|
|
251
|
+
} else {
|
|
252
|
+
response = "{\"result\":\(json)}\n"
|
|
253
|
+
}
|
|
254
|
+
response.withCString { ptr in
|
|
255
|
+
_ = send(fd, ptr, strlen(ptr), 0)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|