返回 DeepSeek-Reasonix
remote_window.go
根目录 / desktop / remote_window.go
1 package main
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "fmt"
7 "io"
8 "net"
9 "net/url"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "unicode"
14
15 "reasonix/internal/config"
16 )
17
18 // remoteWindowLaunch is one open-or-repoint request for a host's remote Serve
19 // window. Under the Electron shell the window is a BrowserWindow the shell
20 // owns; HostKey is the non-secret per-host digest that keys it.
21 type remoteWindowLaunch struct {
22 URL string `json:"url"`
23 Title string `json:"title,omitempty"`
24 HostKey string `json:"hostKey,omitempty"`
25 }
26
27 // remoteWindowLifecycleRegistry linearizes window/Serve lifecycle operations
28 // per host while allowing different hosts to proceed independently. begin
29 // advances the host generation before waiting for the mutex: a later explicit
30 // action or SSH status event can therefore supersede an older operation that is
31 // still blocked in EnsureServer. Entries intentionally live for the App process
32 // lifetime; their cardinality is bounded by host identities used in that run.
33 type remoteWindowLifecycleRegistry struct {
34 hosts sync.Map // map[string]*remoteWindowHostLifecycle
35 }
36
37 type remoteWindowHostLifecycle struct {
38 mu sync.Mutex
39 generation atomic.Uint64
40 }
41
42 type remoteWindowHostOperation struct {
43 host *remoteWindowHostLifecycle
44 generation uint64
45 }
46
47 func (r *remoteWindowLifecycleRegistry) begin(hostKey string) remoteWindowHostOperation {
48 value, _ := r.hosts.LoadOrStore(hostKey, &remoteWindowHostLifecycle{})
49 host := value.(*remoteWindowHostLifecycle)
50 return remoteWindowHostOperation{host: host, generation: host.generation.Add(1)}
51 }
52
53 // run executes fn only while this operation is still the newest request for
54 // the host. fn may re-check current after a slow boundary before committing a
55 // window open or navigation.
56 func (op remoteWindowHostOperation) run(fn func(current func() bool) error) error {
57 if op.host == nil {
58 return nil
59 }
60 op.host.mu.Lock()
61 defer op.host.mu.Unlock()
62 current := func() bool { return op.host.generation.Load() == op.generation }
63 if !current() {
64 return nil
65 }
66 return fn(current)
67 }
68
69 func (a *App) beginRemoteWindowHostOperation(hostID string) remoteWindowHostOperation {
70 return a.remoteWindowLifecycles.begin(remoteWindowHostKey(hostID))
71 }
72
73 // isSafeRemoteWindowURL accepts only plain HTTP on localhost or a loopback IP,
74 // with no userinfo, and nothing that could smuggle a file, script, or external
75 // destination through the shell window navigation.
76 func isSafeRemoteWindowURL(raw string) bool {
77 u, err := url.Parse(raw)
78 if err != nil || u.Scheme != "http" || u.Host == "" || u.User != nil {
79 return false
80 }
81 host := strings.TrimSpace(u.Hostname())
82 if strings.EqualFold(host, "localhost") {
83 return true
84 }
85 ip := net.ParseIP(host)
86 return ip != nil && ip.IsLoopback()
87 }
88
89 func remoteWindowTitle(hostID string) string {
90 hostID = strings.TrimSpace(strings.Map(func(r rune) rune {
91 if unicode.IsControl(r) {
92 return -1
93 }
94 return r
95 }, hostID))
96 runes := []rune(hostID)
97 if len(runes) > 80 {
98 hostID = string(runes[:80]) + "…"
99 }
100 if hostID == "" {
101 hostID = "Remote"
102 }
103 return "Reasonix [SSH: " + hostID + "]"
104 }
105
106 // remoteWindowHostKey derives the non-secret per-host identity that keys the
107 // shell's BrowserWindow. It is scoped to the Reasonix home (so two isolated
108 // data homes can each open a window for the same host label) and contains no
109 // URL, token, or user data — only a digest.
110 func remoteWindowHostKey(hostID string) string {
111 h := sha256.New()
112 _, _ = io.WriteString(h, singleInstanceIDPrefix+"|")
113 _, _ = io.WriteString(h, strings.TrimSpace(config.ReasonixHomeDir())+"|")
114 _, _ = io.WriteString(h, hostID)
115 return hex.EncodeToString(h.Sum(nil)[:16])
116 }
117
118 // remoteWindowRegistry records which workspace each host's window is showing,
119 // so a reconnect refresh or a per-workspace stop can act on the right serve.
120 type remoteWindowRegistry struct {
121 mu sync.Mutex
122 workspaces map[string]string // hostKey → workspace the window currently shows
123 }
124
125 func newRemoteWindowRegistry() *remoteWindowRegistry {
126 return &remoteWindowRegistry{workspaces: map[string]string{}}
127 }
128
129 func (r *remoteWindowRegistry) setWorkspace(hostKey, workspace string) {
130 r.mu.Lock()
131 defer r.mu.Unlock()
132 r.workspaces[hostKey] = workspace
133 }
134
135 // workspaceFor returns the workspace the host's window was last opened on
136 // ("" when unknown).
137 func (r *remoteWindowRegistry) workspaceFor(hostKey string) string {
138 r.mu.Lock()
139 defer r.mu.Unlock()
140 return r.workspaces[hostKey]
141 }
142
143 func (r *remoteWindowRegistry) forget(hostKey string) {
144 r.mu.Lock()
145 defer r.mu.Unlock()
146 delete(r.workspaces, hostKey)
147 }
148
149 func (r *remoteWindowRegistry) forgetAll() {
150 r.mu.Lock()
151 defer r.mu.Unlock()
152 r.workspaces = map[string]string{}
153 }
154
155 // openRemoteWindowForHost opens (or re-points) the host's web window at rawURL.
156 // The window open is deliberately the last step: the caller must already have
157 // a live Serve and loopback tunnel for the target workspace. A failure here is
158 // delivered to the caller while the Serve stays ready for the target
159 // workspace; the window can simply be opened again (the Serve is reused) and
160 // any previous window is left in place until then.
161 func (a *App) openRemoteWindowForHost(hostID, workspace, rawURL string) error {
162 hostKey := remoteWindowHostKey(hostID)
163 if a.remoteWindows != nil {
164 a.remoteWindows.setWorkspace(hostKey, workspace)
165 }
166 launch := remoteWindowLaunch{
167 URL: rawURL,
168 Title: remoteWindowTitle(hostID),
169 HostKey: hostKey,
170 }
171 if !isSafeRemoteWindowURL(launch.URL) {
172 return fmt.Errorf("remote window URL must use HTTP on loopback")
173 }
174 if a.remoteWindowOpener != nil {
175 return a.remoteWindowOpener(launch)
176 }
177 if a.hostMode() {
178 return a.hostShell.openRemoteWindow(launch)
179 }
180 return fmt.Errorf("remote windows require the Electron desktop shell")
181 }
182
183 // remoteWindowWorkspace reports which workspace the host's web window is
184 // currently showing ("" when no window or pre-tracking open).
185 func (a *App) remoteWindowWorkspace(hostID string) string {
186 if a.remoteWindows == nil {
187 return ""
188 }
189 return a.remoteWindows.workspaceFor(remoteWindowHostKey(hostID))
190 }
191
192 // closeRemoteWindowForHost closes the host's web window. Called on explicit
193 // disconnect, stop-server, host removal, and deterministic SSH failure.
194 func (a *App) closeRemoteWindowForHost(hostID string) {
195 hostKey := remoteWindowHostKey(hostID)
196 if a.hostMode() {
197 a.hostShell.closeRemoteWindow(hostKey)
198 }
199 if a.remoteWindows == nil {
200 return
201 }
202 a.remoteWindows.forget(hostKey)
203 }
204
205 func (a *App) hasRemoteWindow(hostID string) bool {
206 if a.hostMode() {
207 return a.hostShell.hasRemoteWindow(remoteWindowHostKey(hostID))
208 }
209 return false
210 }
211
212 func (a *App) closeAllRemoteWindows() {
213 if a.hostMode() {
214 a.hostShell.closeAllRemoteWindows()
215 }
216 if a.remoteWindows == nil {
217 return
218 }
219 a.remoteWindows.forgetAll()
220 }
221
221 lines GO