| 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 |