@wangyaoshen/remux 0.3.8-dev.a8ceb0c

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.
Files changed (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
@@ -0,0 +1,342 @@
1
+ // Package client implements the WebSocket client for connecting to remux servers.
2
+ // It handles authentication, terminal I/O streaming, and control messages.
3
+ package client
4
+
5
+ import (
6
+ "encoding/json"
7
+ "fmt"
8
+ "net/url"
9
+ "sync"
10
+ "time"
11
+
12
+ "github.com/gorilla/websocket"
13
+ )
14
+
15
+ // Host represents a saved remux server connection.
16
+ type Host struct {
17
+ Name string `json:"name"`
18
+ URL string `json:"url"`
19
+ Token string `json:"token"`
20
+ }
21
+
22
+ // Session represents a remote terminal session on a host.
23
+ type Session struct {
24
+ HostName string
25
+ Name string
26
+ Attached bool
27
+ Windows int
28
+ }
29
+
30
+ // ControlMessage is a message sent/received on the control WebSocket.
31
+ type ControlMessage struct {
32
+ Type string `json:"type"`
33
+ ClientID string `json:"clientId,omitempty"`
34
+ RequiresPassword bool `json:"requiresPassword,omitempty"`
35
+ Session string `json:"session,omitempty"`
36
+ Sessions []SessionSummary `json:"sessions,omitempty"`
37
+ State *StateSnapshot `json:"state,omitempty"`
38
+ Reason string `json:"reason,omitempty"`
39
+ Message string `json:"message,omitempty"`
40
+ PaneID string `json:"paneId,omitempty"`
41
+ Text string `json:"text,omitempty"`
42
+ Lines int `json:"lines,omitempty"`
43
+ Name string `json:"name,omitempty"`
44
+ }
45
+
46
+ // SessionSummary matches remux's TmuxSessionSummary.
47
+ type SessionSummary struct {
48
+ Name string `json:"name"`
49
+ Attached bool `json:"attached"`
50
+ Windows int `json:"windows"`
51
+ }
52
+
53
+ // StateSnapshot matches remux's TmuxStateSnapshot.
54
+ type StateSnapshot struct {
55
+ Sessions []SessionState `json:"sessions"`
56
+ CapturedAt string `json:"capturedAt"`
57
+ }
58
+
59
+ // SessionState matches remux's TmuxSessionState.
60
+ type SessionState struct {
61
+ Name string `json:"name"`
62
+ Attached bool `json:"attached"`
63
+ Windows int `json:"windows"`
64
+ WindowStates []WindowState `json:"windowStates"`
65
+ }
66
+
67
+ // WindowState matches remux's TmuxWindowState.
68
+ type WindowState struct {
69
+ Index int `json:"index"`
70
+ Name string `json:"name"`
71
+ Active bool `json:"active"`
72
+ PaneCount int `json:"paneCount"`
73
+ Panes []PaneState `json:"panes"`
74
+ }
75
+
76
+ // PaneState matches remux's TmuxPaneState.
77
+ type PaneState struct {
78
+ Index int `json:"index"`
79
+ ID string `json:"id"`
80
+ CurrentCommand string `json:"currentCommand"`
81
+ Active bool `json:"active"`
82
+ Width int `json:"width"`
83
+ Height int `json:"height"`
84
+ Zoomed bool `json:"zoomed"`
85
+ }
86
+
87
+ // Connection manages a WebSocket connection to a single remux server.
88
+ type Connection struct {
89
+ host Host
90
+ password string
91
+
92
+ controlConn *websocket.Conn
93
+ terminalConn *websocket.Conn
94
+ clientID string
95
+
96
+ // Callbacks
97
+ onTerminalData func(data []byte)
98
+ onStateUpdate func(state *StateSnapshot)
99
+ onSessionList func(sessions []SessionSummary)
100
+ onAttached func(session string)
101
+ onError func(err error)
102
+
103
+ mu sync.Mutex
104
+ closed bool
105
+ }
106
+
107
+ // NewConnection creates a new connection to a remux host.
108
+ func NewConnection(host Host, password string) *Connection {
109
+ return &Connection{
110
+ host: host,
111
+ password: password,
112
+ }
113
+ }
114
+
115
+ // OnTerminalData sets the callback for terminal output data.
116
+ func (c *Connection) OnTerminalData(fn func(data []byte)) { c.onTerminalData = fn }
117
+
118
+ // OnStateUpdate sets the callback for state snapshot updates.
119
+ func (c *Connection) OnStateUpdate(fn func(state *StateSnapshot)) { c.onStateUpdate = fn }
120
+
121
+ // OnSessionList sets the callback for session picker events.
122
+ func (c *Connection) OnSessionList(fn func(sessions []SessionSummary)) { c.onSessionList = fn }
123
+
124
+ // OnAttached sets the callback for successful session attachment.
125
+ func (c *Connection) OnAttached(fn func(session string)) { c.onAttached = fn }
126
+
127
+ // OnError sets the callback for connection errors.
128
+ func (c *Connection) OnError(fn func(err error)) { c.onError = fn }
129
+
130
+ // Connect establishes the control and terminal WebSocket connections.
131
+ func (c *Connection) Connect() error {
132
+ controlURL, err := c.buildWSURL("/ws/control")
133
+ if err != nil {
134
+ return fmt.Errorf("build control URL: %w", err)
135
+ }
136
+
137
+ dialer := websocket.Dialer{
138
+ HandshakeTimeout: 10 * time.Second,
139
+ }
140
+
141
+ c.controlConn, _, err = dialer.Dial(controlURL, nil)
142
+ if err != nil {
143
+ return fmt.Errorf("connect control: %w", err)
144
+ }
145
+
146
+ // Authenticate on control channel.
147
+ authMsg := map[string]interface{}{
148
+ "type": "auth",
149
+ "token": c.host.Token,
150
+ }
151
+ if c.password != "" {
152
+ authMsg["password"] = c.password
153
+ }
154
+ if err := c.controlConn.WriteJSON(authMsg); err != nil {
155
+ return fmt.Errorf("send auth: %w", err)
156
+ }
157
+
158
+ // Read auth response.
159
+ var authResp ControlMessage
160
+ if err := c.controlConn.ReadJSON(&authResp); err != nil {
161
+ return fmt.Errorf("read auth response: %w", err)
162
+ }
163
+ if authResp.Type == "auth_error" {
164
+ return fmt.Errorf("auth failed: %s", authResp.Reason)
165
+ }
166
+ if authResp.Type != "auth_ok" {
167
+ return fmt.Errorf("unexpected auth response: %s", authResp.Type)
168
+ }
169
+ c.clientID = authResp.ClientID
170
+
171
+ // Connect terminal WebSocket.
172
+ terminalURL, err := c.buildWSURL("/ws/terminal")
173
+ if err != nil {
174
+ return fmt.Errorf("build terminal URL: %w", err)
175
+ }
176
+
177
+ c.terminalConn, _, err = dialer.Dial(terminalURL, nil)
178
+ if err != nil {
179
+ return fmt.Errorf("connect terminal: %w", err)
180
+ }
181
+
182
+ // Authenticate on terminal channel.
183
+ termAuthMsg := map[string]interface{}{
184
+ "type": "auth",
185
+ "token": c.host.Token,
186
+ "clientId": c.clientID,
187
+ }
188
+ if c.password != "" {
189
+ termAuthMsg["password"] = c.password
190
+ }
191
+ if err := c.terminalConn.WriteJSON(termAuthMsg); err != nil {
192
+ return fmt.Errorf("send terminal auth: %w", err)
193
+ }
194
+
195
+ // Start read loops.
196
+ go c.readControlLoop()
197
+ go c.readTerminalLoop()
198
+
199
+ return nil
200
+ }
201
+
202
+ // SelectSession sends a select_session control message.
203
+ func (c *Connection) SelectSession(session string) error {
204
+ return c.sendControl(map[string]interface{}{
205
+ "type": "select_session",
206
+ "session": session,
207
+ })
208
+ }
209
+
210
+ // SendInput writes raw terminal input to the terminal WebSocket.
211
+ func (c *Connection) SendInput(data string) error {
212
+ c.mu.Lock()
213
+ defer c.mu.Unlock()
214
+ if c.terminalConn == nil {
215
+ return fmt.Errorf("terminal not connected")
216
+ }
217
+ return c.terminalConn.WriteMessage(websocket.TextMessage, []byte(data))
218
+ }
219
+
220
+ // SendResize sends a resize message to the terminal WebSocket.
221
+ func (c *Connection) SendResize(cols, rows int) error {
222
+ msg, _ := json.Marshal(map[string]interface{}{
223
+ "type": "resize",
224
+ "cols": cols,
225
+ "rows": rows,
226
+ })
227
+ c.mu.Lock()
228
+ defer c.mu.Unlock()
229
+ if c.terminalConn == nil {
230
+ return fmt.Errorf("terminal not connected")
231
+ }
232
+ return c.terminalConn.WriteMessage(websocket.TextMessage, msg)
233
+ }
234
+
235
+ // Close disconnects both WebSocket connections.
236
+ func (c *Connection) Close() {
237
+ c.mu.Lock()
238
+ defer c.mu.Unlock()
239
+ c.closed = true
240
+ if c.controlConn != nil {
241
+ c.controlConn.Close()
242
+ }
243
+ if c.terminalConn != nil {
244
+ c.terminalConn.Close()
245
+ }
246
+ }
247
+
248
+ // ClientID returns the assigned client ID.
249
+ func (c *Connection) ClientID() string {
250
+ return c.clientID
251
+ }
252
+
253
+ func (c *Connection) sendControl(msg map[string]interface{}) error {
254
+ c.mu.Lock()
255
+ defer c.mu.Unlock()
256
+ if c.controlConn == nil {
257
+ return fmt.Errorf("control not connected")
258
+ }
259
+ return c.controlConn.WriteJSON(msg)
260
+ }
261
+
262
+ func (c *Connection) readControlLoop() {
263
+ for {
264
+ _, raw, err := c.controlConn.ReadMessage()
265
+ if err != nil {
266
+ c.mu.Lock()
267
+ closed := c.closed
268
+ c.mu.Unlock()
269
+ if !closed && c.onError != nil {
270
+ c.onError(fmt.Errorf("control read: %w", err))
271
+ }
272
+ return
273
+ }
274
+
275
+ var msg ControlMessage
276
+ if err := json.Unmarshal(raw, &msg); err != nil {
277
+ continue
278
+ }
279
+
280
+ switch msg.Type {
281
+ case "tmux_state":
282
+ if c.onStateUpdate != nil && msg.State != nil {
283
+ c.onStateUpdate(msg.State)
284
+ }
285
+ case "session_picker":
286
+ if c.onSessionList != nil {
287
+ c.onSessionList(msg.Sessions)
288
+ }
289
+ case "attached":
290
+ if c.onAttached != nil {
291
+ c.onAttached(msg.Session)
292
+ }
293
+ case "error":
294
+ if c.onError != nil {
295
+ c.onError(fmt.Errorf("server error: %s", msg.Message))
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ func (c *Connection) readTerminalLoop() {
302
+ for {
303
+ msgType, data, err := c.terminalConn.ReadMessage()
304
+ if err != nil {
305
+ c.mu.Lock()
306
+ closed := c.closed
307
+ c.mu.Unlock()
308
+ if !closed && c.onError != nil {
309
+ c.onError(fmt.Errorf("terminal read: %w", err))
310
+ }
311
+ return
312
+ }
313
+
314
+ if msgType == websocket.TextMessage || msgType == websocket.BinaryMessage {
315
+ if c.onTerminalData != nil {
316
+ c.onTerminalData(data)
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ func (c *Connection) buildWSURL(path string) (string, error) {
323
+ u, err := url.Parse(c.host.URL)
324
+ if err != nil {
325
+ return "", err
326
+ }
327
+
328
+ switch u.Scheme {
329
+ case "https":
330
+ u.Scheme = "wss"
331
+ default:
332
+ u.Scheme = "ws"
333
+ }
334
+
335
+ u.Path = path
336
+ q := u.Query()
337
+ q.Set("token", c.host.Token)
338
+ u.RawQuery = q.Encode()
339
+
340
+ return u.String(), nil
341
+ }
342
+
@@ -0,0 +1,141 @@
1
+ // Package client implements multi-host connection management.
2
+ package client
3
+
4
+ import (
5
+ "fmt"
6
+ "sync"
7
+ )
8
+
9
+ // HostManager manages connections to multiple remux hosts and provides
10
+ // a unified view of all sessions across hosts.
11
+ type HostManager struct {
12
+ mu sync.RWMutex
13
+ connections map[string]*Connection // keyed by host name
14
+ hosts []Host
15
+ sessions []Session // aggregated from all hosts
16
+ onChange func() // called when session list changes
17
+ }
18
+
19
+ // NewHostManager creates a new multi-host manager.
20
+ func NewHostManager() *HostManager {
21
+ return &HostManager{
22
+ connections: make(map[string]*Connection),
23
+ }
24
+ }
25
+
26
+ // OnChange sets a callback invoked when the aggregated session list changes.
27
+ func (m *HostManager) OnChange(fn func()) {
28
+ m.onChange = fn
29
+ }
30
+
31
+ // AddHost connects to a remux host and starts tracking its sessions.
32
+ func (m *HostManager) AddHost(host Host, password string) error {
33
+ conn := NewConnection(host, password)
34
+
35
+ conn.OnStateUpdate(func(state *StateSnapshot) {
36
+ m.mu.Lock()
37
+ m.updateSessionsFromState(host.Name, state)
38
+ m.mu.Unlock()
39
+ if m.onChange != nil {
40
+ m.onChange()
41
+ }
42
+ })
43
+
44
+ conn.OnSessionList(func(sessions []SessionSummary) {
45
+ m.mu.Lock()
46
+ m.updateSessionsFromList(host.Name, sessions)
47
+ m.mu.Unlock()
48
+ if m.onChange != nil {
49
+ m.onChange()
50
+ }
51
+ })
52
+
53
+ if err := conn.Connect(); err != nil {
54
+ return fmt.Errorf("connect to %s: %w", host.Name, err)
55
+ }
56
+
57
+ m.mu.Lock()
58
+ m.connections[host.Name] = conn
59
+ m.hosts = append(m.hosts, host)
60
+ m.mu.Unlock()
61
+
62
+ return nil
63
+ }
64
+
65
+ // GetConnection returns the connection for a host.
66
+ func (m *HostManager) GetConnection(hostName string) *Connection {
67
+ m.mu.RLock()
68
+ defer m.mu.RUnlock()
69
+ return m.connections[hostName]
70
+ }
71
+
72
+ // Sessions returns the current aggregated session list.
73
+ func (m *HostManager) Sessions() []Session {
74
+ m.mu.RLock()
75
+ defer m.mu.RUnlock()
76
+ result := make([]Session, len(m.sessions))
77
+ copy(result, m.sessions)
78
+ return result
79
+ }
80
+
81
+ // Hosts returns the list of connected hosts.
82
+ func (m *HostManager) Hosts() []Host {
83
+ m.mu.RLock()
84
+ defer m.mu.RUnlock()
85
+ result := make([]Host, len(m.hosts))
86
+ copy(result, m.hosts)
87
+ return result
88
+ }
89
+
90
+ // Close disconnects from all hosts.
91
+ func (m *HostManager) Close() {
92
+ m.mu.Lock()
93
+ defer m.mu.Unlock()
94
+ for _, conn := range m.connections {
95
+ conn.Close()
96
+ }
97
+ m.connections = make(map[string]*Connection)
98
+ }
99
+
100
+ func (m *HostManager) updateSessionsFromState(hostName string, state *StateSnapshot) {
101
+ // Remove old sessions for this host.
102
+ filtered := m.sessions[:0]
103
+ for _, s := range m.sessions {
104
+ if s.HostName != hostName {
105
+ filtered = append(filtered, s)
106
+ }
107
+ }
108
+
109
+ // Add new sessions from state.
110
+ for _, ss := range state.Sessions {
111
+ filtered = append(filtered, Session{
112
+ HostName: hostName,
113
+ Name: ss.Name,
114
+ Attached: ss.Attached,
115
+ Windows: ss.Windows,
116
+ })
117
+ }
118
+
119
+ m.sessions = filtered
120
+ }
121
+
122
+ func (m *HostManager) updateSessionsFromList(hostName string, sessions []SessionSummary) {
123
+ filtered := m.sessions[:0]
124
+ for _, s := range m.sessions {
125
+ if s.HostName != hostName {
126
+ filtered = append(filtered, s)
127
+ }
128
+ }
129
+
130
+ for _, ss := range sessions {
131
+ filtered = append(filtered, Session{
132
+ HostName: hostName,
133
+ Name: ss.Name,
134
+ Attached: ss.Attached,
135
+ Windows: ss.Windows,
136
+ })
137
+ }
138
+
139
+ m.sessions = filtered
140
+ }
141
+
@@ -0,0 +1,81 @@
1
+ package config
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "sync"
8
+ )
9
+
10
+ // ScrollbackCache caches terminal scrollback per session for instant
11
+ // re-scroll and offline viewing. Stored in ~/.remux/cache/<host>/<session>.json.
12
+ type ScrollbackCache struct {
13
+ mu sync.Mutex
14
+ cacheDir string
15
+ }
16
+
17
+ // CachedSession is the on-disk format for cached session data.
18
+ type CachedSession struct {
19
+ Lines []string `json:"lines"`
20
+ LastSeq int `json:"lastSeq"`
21
+ Cursor [2]int `json:"cursor"`
22
+ Cols int `json:"cols"`
23
+ Rows int `json:"rows"`
24
+ }
25
+
26
+ // NewScrollbackCache creates a cache rooted at ~/.remux/cache/.
27
+ func NewScrollbackCache() *ScrollbackCache {
28
+ home, _ := os.UserHomeDir()
29
+ return &ScrollbackCache{
30
+ cacheDir: filepath.Join(home, ".remux", "cache"),
31
+ }
32
+ }
33
+
34
+ // Save writes cached session data to disk.
35
+ func (c *ScrollbackCache) Save(hostName, sessionName string, data *CachedSession) error {
36
+ c.mu.Lock()
37
+ defer c.mu.Unlock()
38
+
39
+ dir := filepath.Join(c.cacheDir, sanitize(hostName))
40
+ if err := os.MkdirAll(dir, 0700); err != nil {
41
+ return err
42
+ }
43
+
44
+ bytes, err := json.Marshal(data)
45
+ if err != nil {
46
+ return err
47
+ }
48
+
49
+ return os.WriteFile(filepath.Join(dir, sanitize(sessionName)+".json"), bytes, 0600)
50
+ }
51
+
52
+ // Load reads cached session data from disk.
53
+ func (c *ScrollbackCache) Load(hostName, sessionName string) (*CachedSession, error) {
54
+ c.mu.Lock()
55
+ defer c.mu.Unlock()
56
+
57
+ path := filepath.Join(c.cacheDir, sanitize(hostName), sanitize(sessionName)+".json")
58
+ data, err := os.ReadFile(path)
59
+ if err != nil {
60
+ return nil, err
61
+ }
62
+
63
+ var cached CachedSession
64
+ if err := json.Unmarshal(data, &cached); err != nil {
65
+ return nil, err
66
+ }
67
+
68
+ return &cached, nil
69
+ }
70
+
71
+ func sanitize(s string) string {
72
+ result := make([]byte, 0, len(s))
73
+ for _, c := range []byte(s) {
74
+ if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' {
75
+ result = append(result, c)
76
+ } else {
77
+ result = append(result, '_')
78
+ }
79
+ }
80
+ return string(result)
81
+ }
@@ -0,0 +1,53 @@
1
+ // Package config handles loading and saving host configurations.
2
+ package config
3
+
4
+ import (
5
+ "encoding/json"
6
+ "os"
7
+ "path/filepath"
8
+
9
+ "github.com/eisber/remux/tui/client"
10
+ )
11
+
12
+ // ConfigFile is the structure stored in ~/.remux/hosts.json.
13
+ type ConfigFile struct {
14
+ Hosts []client.Host `json:"hosts"`
15
+ }
16
+
17
+ // DefaultConfigPath returns the default config file location.
18
+ func DefaultConfigPath() string {
19
+ home, _ := os.UserHomeDir()
20
+ return filepath.Join(home, ".remux", "hosts.json")
21
+ }
22
+
23
+ // Load reads hosts from the config file.
24
+ func Load(path string) (*ConfigFile, error) {
25
+ data, err := os.ReadFile(path)
26
+ if err != nil {
27
+ if os.IsNotExist(err) {
28
+ return &ConfigFile{}, nil
29
+ }
30
+ return nil, err
31
+ }
32
+
33
+ var cfg ConfigFile
34
+ if err := json.Unmarshal(data, &cfg); err != nil {
35
+ return nil, err
36
+ }
37
+ return &cfg, nil
38
+ }
39
+
40
+ // Save writes hosts to the config file.
41
+ func Save(path string, cfg *ConfigFile) error {
42
+ dir := filepath.Dir(path)
43
+ if err := os.MkdirAll(dir, 0700); err != nil {
44
+ return err
45
+ }
46
+
47
+ data, err := json.MarshalIndent(cfg, "", " ")
48
+ if err != nil {
49
+ return err
50
+ }
51
+ return os.WriteFile(path, data, 0600)
52
+ }
53
+
@@ -0,0 +1,89 @@
1
+ package config
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+
8
+ "github.com/eisber/remux/tui/client"
9
+ )
10
+
11
+ func TestLoadNonExistent(t *testing.T) {
12
+ cfg, err := Load(filepath.Join(t.TempDir(), "nonexistent.json"))
13
+ if err != nil {
14
+ t.Fatalf("expected nil error for missing file, got: %v", err)
15
+ }
16
+ if len(cfg.Hosts) != 0 {
17
+ t.Fatalf("expected 0 hosts, got: %d", len(cfg.Hosts))
18
+ }
19
+ }
20
+
21
+ func TestSaveAndLoad(t *testing.T) {
22
+ dir := t.TempDir()
23
+ path := filepath.Join(dir, "config", "hosts.json")
24
+
25
+ cfg := &ConfigFile{
26
+ Hosts: []client.Host{
27
+ {Name: "devbox", URL: "http://localhost:8767", Token: "abc123"},
28
+ {Name: "cloud", URL: "https://my.devtunnels.ms", Token: "xyz"},
29
+ },
30
+ }
31
+
32
+ if err := Save(path, cfg); err != nil {
33
+ t.Fatalf("save failed: %v", err)
34
+ }
35
+
36
+ loaded, err := Load(path)
37
+ if err != nil {
38
+ t.Fatalf("load failed: %v", err)
39
+ }
40
+
41
+ if len(loaded.Hosts) != 2 {
42
+ t.Fatalf("expected 2 hosts, got: %d", len(loaded.Hosts))
43
+ }
44
+ if loaded.Hosts[0].Name != "devbox" {
45
+ t.Fatalf("expected host name 'devbox', got: %s", loaded.Hosts[0].Name)
46
+ }
47
+ if loaded.Hosts[1].Token != "xyz" {
48
+ t.Fatalf("expected token 'xyz', got: %s", loaded.Hosts[1].Token)
49
+ }
50
+ }
51
+
52
+ func TestSaveCreatesDirectories(t *testing.T) {
53
+ dir := t.TempDir()
54
+ path := filepath.Join(dir, "deep", "nested", "hosts.json")
55
+
56
+ cfg := &ConfigFile{Hosts: []client.Host{{Name: "test", URL: "http://localhost", Token: "t"}}}
57
+ if err := Save(path, cfg); err != nil {
58
+ t.Fatalf("save failed: %v", err)
59
+ }
60
+
61
+ if _, err := os.Stat(path); os.IsNotExist(err) {
62
+ t.Fatal("config file was not created")
63
+ }
64
+ }
65
+
66
+ func TestSaveFilePermissions(t *testing.T) {
67
+ if os.Getenv("OS") == "Windows_NT" {
68
+ t.Skip("file permissions test not reliable on Windows")
69
+ }
70
+
71
+ dir := t.TempDir()
72
+ path := filepath.Join(dir, "hosts.json")
73
+
74
+ cfg := &ConfigFile{Hosts: []client.Host{{Name: "test", URL: "http://localhost", Token: "secret"}}}
75
+ if err := Save(path, cfg); err != nil {
76
+ t.Fatalf("save failed: %v", err)
77
+ }
78
+
79
+ info, err := os.Stat(path)
80
+ if err != nil {
81
+ t.Fatalf("stat failed: %v", err)
82
+ }
83
+
84
+ perm := info.Mode().Perm()
85
+ if perm&0077 != 0 {
86
+ t.Fatalf("config file should not be world/group readable, got: %o", perm)
87
+ }
88
+ }
89
+