@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.
- 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,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
|
+
|