返回 DeepSeek-Reasonix
remote_window.go
根目录 / desktop / remote_window.go
1 package main
2
3 import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "io"
11 "log/slog"
12 "net"
13 "net/http"
14 "net/url"
15 "os"
16 "os/exec"
17 "path/filepath"
18 goruntime "runtime"
19 "strconv"
20 "strings"
21 "sync"
22 "sync/atomic"
23 "time"
24 "unicode"
25
26 "github.com/wailsapp/wails/v2/pkg/options"
27 "github.com/wailsapp/wails/v2/pkg/runtime"
28
29 "reasonix/internal/config"
30 )
31
32 const (
33 remoteWindowTicketArgPrefix = "--remote-window-ticket="
34 remoteWindowHostArgPrefix = "--remote-window-host="
35 remoteWindowOwnerArgPrefix = "--remote-window-owner="
36 remoteWindowParentArgPrefix = "--remote-window-parent="
37 remoteWindowTicketPrefix = ".remote-window-"
38 remoteWindowTicketTTL = 2 * time.Minute
39 remoteWindowTicketMaxBytes = 16 * 1024
40 remoteWindowInstancePrefix = "com.reasonix.desktop.remote."
41 )
42
43 // remoteWindowLaunch is a one-shot handoff from the primary Reasonix process to
44 // a lightweight web-window child process. The URL carries the local tunnel token,
45 // so the descriptor lives in a mode-0600 ticket file instead of the process
46 // arguments. HostKey is the non-secret per-host digest used both to derive the
47 // child's Wails single-instance identity and to verify the argv host matches the
48 // ticket before the child consumes it.
49 type remoteWindowLaunch struct {
50 URL string `json:"url"`
51 Title string `json:"title,omitempty"`
52 HostKey string `json:"hostKey,omitempty"`
53 }
54
55 // remoteWindowLifecycleRegistry linearizes window/Serve lifecycle operations
56 // per host while allowing different hosts to proceed independently. begin
57 // advances the host generation before waiting for the mutex: a later explicit
58 // action or SSH status event can therefore supersede an older operation that is
59 // still blocked in EnsureServer. Entries intentionally live for the App process
60 // lifetime; their cardinality is bounded by host identities used in that run.
61 type remoteWindowLifecycleRegistry struct {
62 hosts sync.Map // map[string]*remoteWindowHostLifecycle
63 }
64
65 type remoteWindowHostLifecycle struct {
66 mu sync.Mutex
67 generation atomic.Uint64
68 }
69
70 type remoteWindowHostOperation struct {
71 host *remoteWindowHostLifecycle
72 generation uint64
73 }
74
75 func (r *remoteWindowLifecycleRegistry) begin(hostKey string) remoteWindowHostOperation {
76 value, _ := r.hosts.LoadOrStore(hostKey, &remoteWindowHostLifecycle{})
77 host := value.(*remoteWindowHostLifecycle)
78 return remoteWindowHostOperation{host: host, generation: host.generation.Add(1)}
79 }
80
81 // run executes fn only while this operation is still the newest request for
82 // the host. fn may re-check current after a slow boundary before committing a
83 // window spawn or navigation.
84 func (op remoteWindowHostOperation) run(fn func(current func() bool) error) error {
85 if op.host == nil {
86 return nil
87 }
88 op.host.mu.Lock()
89 defer op.host.mu.Unlock()
90 current := func() bool { return op.host.generation.Load() == op.generation }
91 if !current() {
92 return nil
93 }
94 return fn(current)
95 }
96
97 func (a *App) beginRemoteWindowHostOperation(hostID string) remoteWindowHostOperation {
98 return a.remoteWindowLifecycles.begin(remoteWindowHostKey(hostID))
99 }
100
101 // remoteWindowTicketPath validates the ticket name and resolves it inside the
102 // Reasonix private state directory. Only the bare generated name is accepted —
103 // never a path, a traversal, or a foreign filename.
104 func remoteWindowTicketPath(ticket string) (string, error) {
105 if ticket == "" || filepath.Base(ticket) != ticket || !strings.HasPrefix(ticket, remoteWindowTicketPrefix) {
106 return "", fmt.Errorf("invalid remote window ticket")
107 }
108 dir := strings.TrimSpace(config.MemoryUserDir())
109 if dir == "" {
110 return "", fmt.Errorf("cannot resolve remote window state directory")
111 }
112 return filepath.Join(dir, ticket), nil
113 }
114
115 func writeRemoteWindowLaunch(launch remoteWindowLaunch) (string, error) {
116 if !isSafeRemoteWindowURL(launch.URL) {
117 return "", fmt.Errorf("remote window URL must use HTTP on loopback")
118 }
119 if strings.TrimSpace(launch.HostKey) == "" {
120 return "", fmt.Errorf("remote window ticket missing host identity")
121 }
122 dir := config.MemoryUserDir()
123 if err := os.MkdirAll(dir, 0o700); err != nil {
124 return "", fmt.Errorf("create remote window state directory: %w", err)
125 }
126 f, err := os.CreateTemp(dir, remoteWindowTicketPrefix)
127 if err != nil {
128 return "", fmt.Errorf("create remote window ticket: %w", err)
129 }
130 path := f.Name()
131 remove := true
132 defer func() {
133 _ = f.Close()
134 if remove {
135 _ = os.Remove(path)
136 }
137 }()
138 if err := f.Chmod(0o600); err != nil {
139 return "", fmt.Errorf("secure remote window ticket: %w", err)
140 }
141 if err := json.NewEncoder(f).Encode(launch); err != nil {
142 return "", fmt.Errorf("write remote window ticket: %w", err)
143 }
144 if err := f.Sync(); err != nil {
145 return "", fmt.Errorf("sync remote window ticket: %w", err)
146 }
147 if err := f.Close(); err != nil {
148 return "", fmt.Errorf("close remote window ticket: %w", err)
149 }
150 remove = false
151 return filepath.Base(path), nil
152 }
153
154 // consumeRemoteWindowLaunch reads and immediately deletes the ticket. A ticket
155 // is one-shot: whoever consumes it (the first window to win the per-host
156 // single-instance lock, or the existing window receiving a handoff) owns the
157 // navigation. The file must be a regular 0600 file within the size bound; on
158 // Unix, broader permissions or symlinks are rejected outright.
159 func consumeRemoteWindowLaunch(ticket string) (*remoteWindowLaunch, error) {
160 path, err := remoteWindowTicketPath(ticket)
161 if err != nil {
162 return nil, err
163 }
164 info, err := os.Lstat(path)
165 if err != nil {
166 return nil, fmt.Errorf("inspect remote window ticket: %w", err)
167 }
168 defer os.Remove(path)
169 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
170 return nil, fmt.Errorf("remote window ticket is not a regular file")
171 }
172 if info.Size() <= 0 || info.Size() > remoteWindowTicketMaxBytes {
173 return nil, fmt.Errorf("remote window ticket has an invalid size")
174 }
175 // Strict TTL: the ticket must be consumed within remoteWindowTicketTTL of
176 // being written. This bounds leftover token files even when the spawning
177 // process died before its time.AfterFunc backstop could remove them.
178 if time.Since(info.ModTime()) > remoteWindowTicketTTL {
179 return nil, fmt.Errorf("remote window ticket has expired")
180 }
181 // Windows does not expose Unix owner/group permission bits through Stat;
182 // CreateTemp still creates the file for the current user, while ACLs remain
183 // governed by the private user state directory.
184 if goruntime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
185 return nil, fmt.Errorf("remote window ticket permissions are too broad")
186 }
187 data, err := os.ReadFile(path)
188 if err != nil {
189 return nil, fmt.Errorf("read remote window ticket: %w", err)
190 }
191 var launch remoteWindowLaunch
192 if err := json.Unmarshal(data, &launch); err != nil {
193 return nil, fmt.Errorf("decode remote window ticket: %w", err)
194 }
195 if !isSafeRemoteWindowURL(launch.URL) {
196 return nil, fmt.Errorf("remote window URL must use HTTP on loopback")
197 }
198 if strings.TrimSpace(launch.HostKey) == "" {
199 return nil, fmt.Errorf("remote window ticket missing host identity")
200 }
201 return &launch, nil
202 }
203
204 // isSafeRemoteWindowURL accepts only plain HTTP on localhost or a loopback IP,
205 // with no userinfo, and nothing that could smuggle a file, script, or external
206 // destination through the webview navigation.
207 func isSafeRemoteWindowURL(raw string) bool {
208 u, err := url.Parse(raw)
209 if err != nil || u.Scheme != "http" || u.Host == "" || u.User != nil {
210 return false
211 }
212 host := strings.TrimSpace(u.Hostname())
213 if strings.EqualFold(host, "localhost") {
214 return true
215 }
216 ip := net.ParseIP(host)
217 return ip != nil && ip.IsLoopback()
218 }
219
220 func remoteWindowNavigationJS(raw string) (string, error) {
221 if !isSafeRemoteWindowURL(raw) {
222 return "", fmt.Errorf("remote window URL must use HTTP on loopback")
223 }
224 encoded, err := json.Marshal(raw)
225 if err != nil {
226 return "", err
227 }
228 return "window.location.replace(" + string(encoded) + ");", nil
229 }
230
231 func remoteWindowTitle(hostID string) string {
232 hostID = strings.TrimSpace(strings.Map(func(r rune) rune {
233 if unicode.IsControl(r) {
234 return -1
235 }
236 return r
237 }, hostID))
238 runes := []rune(hostID)
239 if len(runes) > 80 {
240 hostID = string(runes[:80]) + "…"
241 }
242 if hostID == "" {
243 hostID = "Remote"
244 }
245 return "Reasonix [SSH: " + hostID + "]"
246 }
247
248 // remoteWindowHostKey derives the non-secret per-host identity used for the
249 // child window's Wails single-instance lock. It is scoped to the Reasonix home
250 // (so two isolated data homes can each open a window for the same host label)
251 // and contains no URL, token, or user data — only a digest. The child receives
252 // this digest in argv and validates it against the ticket before consuming.
253 func remoteWindowHostKey(hostID string) string {
254 h := sha256.New()
255 _, _ = io.WriteString(h, singleInstanceIDPrefix+"|")
256 _, _ = io.WriteString(h, strings.TrimSpace(config.ReasonixHomeDir())+"|")
257 _, _ = io.WriteString(h, hostID)
258 return hex.EncodeToString(h.Sum(nil)[:16])
259 }
260
261 func newRemoteWindowOwnerID() string {
262 var entropy [16]byte
263 if _, err := rand.Read(entropy[:]); err != nil {
264 panic("generate remote window owner identity: " + err.Error())
265 }
266 return hex.EncodeToString(entropy[:])
267 }
268
269 func isRemoteWindowOwnerID(ownerID string) bool {
270 if len(ownerID) != 32 {
271 return false
272 }
273 _, err := hex.DecodeString(ownerID)
274 return err == nil
275 }
276
277 // remoteWindowInstanceID is the owner-and-host Wails SingleInstanceLock
278 // identity. Different hosts proceed independently, while the same Desktop
279 // reuses its existing host window. A restarted Desktop has a new owner identity
280 // and therefore never adopts a child that its process registry cannot control.
281 func remoteWindowInstanceID(hostKey, ownerID string) string {
282 digest := sha256.Sum256([]byte(hostKey + "|" + ownerID))
283 return remoteWindowInstancePrefix + hex.EncodeToString(digest[:16])
284 }
285
286 // remoteWindowSingleInstanceLock wires the child process's owner-and-host lock.
287 // The second instance never reaches the webview: Wails hands its argv to the
288 // existing window's OnSecondInstanceLaunch and exits at the gate, so the new
289 // ticket is consumed exactly once, by the window that owns the host.
290 func remoteWindowSingleInstanceLock(app *App) *options.SingleInstanceLock {
291 return &options.SingleInstanceLock{
292 UniqueId: remoteWindowInstanceID(app.remoteWindowHostKey, app.remoteWindowOwnerID),
293 OnSecondInstanceLaunch: func(data options.SecondInstanceData) {
294 app.secondInstanceRemoteWindow(data)
295 },
296 }
297 }
298
299 // ── Child process registry (main process) ──
300
301 // remoteWindowChild is one spawned web-window process. gen is bumped per spawn
302 // so a late Wait from an old child can never clear a newer registration.
303 type remoteWindowChild struct {
304 gen uint64
305 pid int
306 proc *os.Process
307 }
308
309 // remoteWindowRegistry tracks, per host, the web-window child processes the
310 // main process spawned. The per-host value is a set: re-opening a host spawns
311 // a short-lived handoff process that exits at the Wails single-instance gate
312 // after passing its ticket to the still-running window, so only that
313 // handoff's own entry may be cleared by its Wait — the surviving window's
314 // entry must stay registered. Closing the window (user or terminal
315 // disconnect) releases only its registration; the remote Serve and the main
316 // process's SSH connection keep running. A real main-process quit terminates
317 // survivors.
318 type remoteWindowRegistry struct {
319 mu sync.Mutex
320 children map[string][]remoteWindowChild // per host, one live window plus transient handoffs
321 nextGen uint64
322 }
323
324 func newRemoteWindowRegistry() *remoteWindowRegistry {
325 return &remoteWindowRegistry{children: map[string][]remoteWindowChild{}}
326 }
327
328 // record registers proc for hostKey and returns its generation. Each spawn is
329 // a distinct entry; replacing the host's window never forgets a live one.
330 func (r *remoteWindowRegistry) record(hostKey string, proc *os.Process) uint64 {
331 r.mu.Lock()
332 defer r.mu.Unlock()
333 gen := r.nextGen
334 r.nextGen++
335 r.children[hostKey] = append(r.children[hostKey], remoteWindowChild{gen: gen, pid: proc.Pid, proc: proc})
336 return gen
337 }
338
339 // clearIf drops exactly the caller's own entry — the Wait result for one
340 // spawned process. A handoff process that exited at the single-instance gate
341 // clears only itself; the window it handed the ticket to stays registered.
342 func (r *remoteWindowRegistry) clearIf(hostKey string, gen uint64, pid int) {
343 r.mu.Lock()
344 defer r.mu.Unlock()
345 entries := r.children[hostKey]
346 for i, child := range entries {
347 if child.gen == gen && child.pid == pid {
348 r.children[hostKey] = append(entries[:i], entries[i+1:]...)
349 if len(r.children[hostKey]) == 0 {
350 delete(r.children, hostKey)
351 }
352 return
353 }
354 }
355 }
356
357 // close terminates every process registered for the host — the live window
358 // and any transient handoffs — and releases the registration immediately.
359 // Killing an already-exited handoff is a no-op error.
360 func (r *remoteWindowRegistry) close(hostKey string) {
361 r.mu.Lock()
362 entries := r.children[hostKey]
363 delete(r.children, hostKey)
364 r.mu.Unlock()
365 for _, child := range entries {
366 if child.proc != nil {
367 _ = child.proc.Kill()
368 }
369 }
370 }
371
372 // has reports whether any child process is currently registered for hostKey —
373 // true while the live window (or a handoff still in flight) exists.
374 func (r *remoteWindowRegistry) has(hostKey string) bool {
375 r.mu.Lock()
376 defer r.mu.Unlock()
377 _, ok := r.children[hostKey]
378 return ok
379 }
380
381 // closeAll terminates every surviving child window. Used only on real main
382 // process shutdown — background (tray) close keeps windows and tunnels alive.
383 func (r *remoteWindowRegistry) closeAll() {
384 r.mu.Lock()
385 all := make([]*os.Process, 0)
386 for _, entries := range r.children {
387 for _, child := range entries {
388 if child.proc != nil {
389 all = append(all, child.proc)
390 }
391 }
392 }
393 r.children = map[string][]remoteWindowChild{}
394 r.mu.Unlock()
395 for _, proc := range all {
396 _ = proc.Kill()
397 }
398 }
399
400 // ── Spawn / open (main process) ──
401
402 // spawnRemoteWindow launches a fresh Reasonix child process for hostKey. Argv
403 // contains only the ticket name, non-secret host/owner identities, and owner
404 // PID; the URL and Serve token travel exclusively in the 0600 ticket. When a
405 // window already exists for this owner and host, the Wails single-instance lock
406 // routes the ticket to it and this process exits at the gate without showing UI.
407 func (a *App) spawnRemoteWindow(hostKey string, launch remoteWindowLaunch) error {
408 ticket, err := writeRemoteWindowLaunch(launch)
409 if err != nil {
410 return err
411 }
412 path, _ := remoteWindowTicketPath(ticket)
413 executable, err := os.Executable()
414 if err != nil {
415 _ = os.Remove(path)
416 return fmt.Errorf("locate Reasonix executable: %w", err)
417 }
418 if !isRemoteWindowOwnerID(a.remoteWindowOwnerID) {
419 _ = os.Remove(path)
420 return fmt.Errorf("remote window owner identity is unavailable")
421 }
422 cmd := exec.Command(
423 executable,
424 remoteWindowTicketArgPrefix+ticket,
425 remoteWindowHostArgPrefix+hostKey,
426 remoteWindowOwnerArgPrefix+a.remoteWindowOwnerID,
427 remoteWindowParentArgPrefix+strconv.Itoa(os.Getpid()),
428 )
429 if err := cmd.Start(); err != nil {
430 _ = os.Remove(path)
431 return fmt.Errorf("start remote Reasonix window: %w", err)
432 }
433 gen := a.remoteWindows.record(hostKey, cmd.Process)
434 // The child (or the existing window it hands off to) normally consumes the
435 // ticket immediately. This bounds any leftover token file if every consumer
436 // exits before reaching the ticket.
437 time.AfterFunc(remoteWindowTicketTTL, func() { _ = os.Remove(path) })
438 go func() {
439 _ = cmd.Wait()
440 a.remoteWindows.clearIf(hostKey, gen, cmd.Process.Pid)
441 }()
442 return nil
443 }
444
445 // watchRemoteWindowOwner closes a child window when the primary Desktop process
446 // that owns its SSH tunnel exits. The owner identity also scopes the Wails
447 // single-instance lock, so a restarted Desktop creates a fresh owned child
448 // instead of handing a ticket to an unregistered survivor from the old process.
449 func (a *App) watchRemoteWindowOwner(ctx context.Context) {
450 pid := a.remoteWindowParentPID
451 if pid <= 0 {
452 return
453 }
454 a.goSafe("remoteWindowOwner", func() {
455 if waitForRemoteWindowOwnerExit(ctx, pid) {
456 runtime.Quit(ctx)
457 }
458 })
459 }
460
461 // openRemoteWindowForHost opens (or re-points) the host's web window at rawURL.
462 // The window open is deliberately the last step: the caller must already have
463 // a live Serve and loopback tunnel for the target workspace. A failure here is
464 // delivered to the caller while the Serve stays ready for the target
465 // workspace; the window can simply be opened again (the Serve is reused) and
466 // any previous window is left in place until then.
467 func (a *App) openRemoteWindowForHost(hostID, rawURL string) error {
468 launch := remoteWindowLaunch{
469 URL: rawURL,
470 Title: remoteWindowTitle(hostID),
471 HostKey: remoteWindowHostKey(hostID),
472 }
473 if a.remoteWindowOpener != nil {
474 return a.remoteWindowOpener(launch)
475 }
476 return a.spawnRemoteWindow(launch.HostKey, launch)
477 }
478
479 // closeRemoteWindowForHost terminates the host's web window. Called on explicit
480 // disconnect, stop-server, host removal, and deterministic SSH failure.
481 func (a *App) closeRemoteWindowForHost(hostID string) {
482 if a.remoteWindows == nil {
483 return
484 }
485 a.remoteWindows.close(remoteWindowHostKey(hostID))
486 }
487
488 func (a *App) hasRemoteWindow(hostID string) bool {
489 if a.remoteWindows == nil {
490 return false
491 }
492 return a.remoteWindows.has(remoteWindowHostKey(hostID))
493 }
494
495 func (a *App) closeAllRemoteWindows() {
496 if a.remoteWindows == nil {
497 return
498 }
499 a.remoteWindows.closeAll()
500 }
501
502 // ── Child process (web window) ──
503
504 // remoteWindowAssetMiddleware replaces the primary frontend with a blank dark
505 // shell while the web window boots, so the child (which exposes no Wails
506 // bindings) never loads the local app. The shell then navigates to the Serve
507 // URL. The main process passes this middleware through untouched.
508 func (a *App) remoteWindowAssetMiddleware() func(http.Handler) http.Handler {
509 return func(next http.Handler) http.Handler {
510 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
511 if a.remoteWindowTicket == "" || (r.URL.Path != "/" && r.URL.Path != "/index.html") {
512 next.ServeHTTP(w, r)
513 return
514 }
515 w.Header().Set("Content-Type", "text/html; charset=utf-8")
516 w.Header().Set("Cache-Control", "no-store")
517 w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
518 _, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><style>html{background:#1a1a2e}</style></head><body></body></html>`))
519 })
520 }
521 }
522
523 // consumeInitialRemoteWindowLaunch consumes the child process's initial ticket
524 // at most once. WebKit invokes OnDomReady for both the embedded blank shell and
525 // the remote Serve page loaded by that shell; the second callback must not try
526 // to consume the already-deleted one-shot ticket and close a healthy window.
527 func (a *App) consumeInitialRemoteWindowLaunch() (*remoteWindowLaunch, bool, error) {
528 a.remoteWindowMu.Lock()
529 if a.remoteWindowTicketConsumed {
530 a.remoteWindowMu.Unlock()
531 return nil, false, nil
532 }
533 a.remoteWindowTicketConsumed = true
534 a.remoteWindowMu.Unlock()
535
536 launch, err := consumeRemoteWindowLaunch(a.remoteWindowTicket)
537 return launch, true, err
538 }
539
540 // domReadyRemoteWindow consumes the launch ticket (guarded by the per-host
541 // single-instance gate, so the second instance never reaches this point) and
542 // navigates the blank shell to the Serve URL. If a handoff already applied a
543 // newer ticket before the first domReady, the initial ticket is discarded, not
544 // applied on top of it. Later domReady callbacks from the remote page are no-ops.
545 func (a *App) domReadyRemoteWindow() {
546 launch, first, err := a.consumeInitialRemoteWindowLaunch()
547 if !first {
548 return
549 }
550 if err != nil {
551 slog.Warn("remote window: reject launch ticket", "err", err)
552 runtime.Quit(a.ctx)
553 return
554 }
555 if launch.HostKey != a.remoteWindowHostKey {
556 slog.Warn("remote window: ticket host does not match window identity")
557 runtime.Quit(a.ctx)
558 return
559 }
560 a.remoteWindowMu.Lock()
561 if a.remoteWindow == nil {
562 a.applyRemoteWindowLaunchLocked(launch, true)
563 }
564 a.remoteWindowMu.Unlock()
565 runtime.WindowCenter(a.ctx)
566 runtime.WindowShow(a.ctx)
567 }
568
569 // secondInstanceRemoteWindow is the existing window's side of the per-host
570 // single-instance handoff: a second open for the same host arrives as this
571 // window's argv. It consumes the new ticket, updates the title, navigates to
572 // the new URL, and restores + focuses the window. Tickets from another host
573 // identity are rejected.
574 func (a *App) secondInstanceRemoteWindow(data options.SecondInstanceData) {
575 ticket := ""
576 for _, arg := range data.Args {
577 if strings.HasPrefix(arg, remoteWindowTicketArgPrefix) {
578 ticket = strings.TrimPrefix(arg, remoteWindowTicketArgPrefix)
579 break
580 }
581 }
582 if ticket == "" {
583 // A second launch without a ticket (e.g. a launcher invocation): just
584 // bring the existing remote window forward.
585 runtime.WindowCenter(a.ctx)
586 runtime.WindowShow(a.ctx)
587 return
588 }
589 launch, err := consumeRemoteWindowLaunch(ticket)
590 if err != nil {
591 slog.Warn("remote window: reject handoff ticket", "err", err)
592 return
593 }
594 a.remoteWindowMu.Lock()
595 if launch.HostKey != a.remoteWindowHostKey {
596 a.remoteWindowMu.Unlock()
597 slog.Warn("remote window: handoff host does not match window identity")
598 return
599 }
600 a.applyRemoteWindowLaunchLocked(launch, false)
601 a.remoteWindowMu.Unlock()
602 }
603
604 // applyRemoteWindowLaunchLocked sets the title, navigates the shell to the new
605 // URL, and restores + focuses the window. The caller holds a.remoteWindowMu so
606 // a handoff arriving before domReady cannot be overridden by the initial
607 // ticket, and vice versa.
608 func (a *App) applyRemoteWindowLaunchLocked(launch *remoteWindowLaunch, initial bool) {
609 if launch.Title != "" {
610 runtime.WindowSetTitle(a.ctx, launch.Title)
611 }
612 if js, err := remoteWindowNavigationJS(launch.URL); err == nil {
613 runtime.WindowExecJS(a.ctx, js)
614 }
615 if !initial && runtime.WindowIsMinimised(a.ctx) {
616 runtime.WindowUnminimise(a.ctx)
617 }
618 runtime.WindowCenter(a.ctx)
619 runtime.WindowShow(a.ctx)
620 a.remoteWindow = launch
621 }
622
622 lines GO