fix(desktop): remove the legacy remote child window

Problem:\nThe same-window Remote Workbench still carried an unreachable child-window launcher and special shell lifecycle, causing Desktop CI unused-code failures and contradicting the selected product model.\n\nRoot cause:\nThe App bindings were migrated to TargetManager, but the old ticket-based Serve HTML launch path and its window-specific startup branches were left behind.\n\nFix:\nRemove remote-window tickets, spawning, middleware, lifecycle branches, and obsolete helpers so every SSH workbench runs in the existing main window.\n\nVerification:\n- cd desktop && go test ./...\n- desktop and root golangci-lint v2.12.2: 0 issues\n- Windows amd64 Desktop cross-compilation\n- wails generate module
This commit is contained in:
SivanCola committed 2026-07-20 21:15:11 +08:00
1 parent c925e14cf4
commit b9e036b84f
9 files changed
+24 -478

No files matched your search

-20
View File
@@ -108,13 +108,6 @@ type PromptHistoryResult struct {
type App struct {
ctx context.Context
// remoteWindow marks the lightweight native shell used for an SSH-backed
// workspace. It deliberately skips local tab/session runtime startup so a
// second window never competes for the primary process's session leases.
remoteWindow *remoteWindowLaunch
remoteWindowNavigated atomic.Bool
remoteWindowOpener func(remoteWindowLaunch) error // test-only injection
// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read
// from bound methods. All bound methods that touch a controller use activeCtrl().
mu sync.RWMutex
@@ -454,9 +447,6 @@ func (a *App) Platform() string {
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.startWindowsWebView2StartupFallback(ctx)
if a.remoteWindow != nil {
return
}
installSystemQuitHook()
a.startTray()
a.enableDeferredRebuildRetry()
@@ -491,9 +481,6 @@ func (a *App) startup(ctx context.Context) {
}
func (a *App) beforeClose(ctx context.Context) bool {
if a.remoteWindow != nil {
return false
}
if a.forceQuit.Swap(false) || consumeSystemQuitRequested() {
return false
}
@@ -806,9 +793,6 @@ func (a *App) snapshotAllTabs() {
// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.
func (a *App) shutdown(context.Context) {
if a.remoteWindow != nil {
return
}
a.stopDeferredRebuildRetry()
a.stopMainThreadWatchdog()
if a.heartbeat != nil {
@@ -877,10 +861,6 @@ func (a *App) domReady(_ context.Context) {
// SA_ONSTACK flags required by Go; this is a no-op outside Linux.
repairWebKitSignalHandlers()
if a.remoteWindow != nil {
a.domReadyRemoteWindow()
return
}
state, ok := loadWindowState()
if ok {
// Validate saved position against current screens. Wails v2 doesn't
+14 -44
View File
@@ -104,33 +104,20 @@ func main() {
if config.SafeModeRequested() {
launch.SafeMode = true
}
var remoteLaunch *remoteWindowLaunch
if launch.RemoteWindowTicket != "" {
var err error
remoteLaunch, err = consumeRemoteWindowLaunch(launch.RemoteWindowTicket)
if err != nil {
println("Error:", err.Error())
return
}
}
var tracker *repair.StartupTracker
trackerOwned := false
if remoteLaunch == nil {
tracker = repair.NewStartupTracker("")
if tracker.SafeModeRecommended() {
launch.SafeMode = true
}
if launch.SafeMode {
_ = os.Setenv("REASONIX_SAFE_MODE", "1")
}
// Begin runs before the Wails single-instance gate, but it refuses to
// overwrite the recorded state while its owner PID is alive, so a duplicate
// launch — which Wails terminates via os.Exit without OnShutdown — never
// counts as a crash toward the Safe Mode threshold.
startupState, _ := tracker.Begin(version, launch.SafeMode)
trackerOwned = startupState.PID == os.Getpid()
tracker := repair.NewStartupTracker("")
if tracker.SafeModeRecommended() {
launch.SafeMode = true
}
if launch.SafeMode {
_ = os.Setenv("REASONIX_SAFE_MODE", "1")
}
// Begin runs before the Wails single-instance gate, but it refuses to
// overwrite the recorded state while its owner PID is alive, so a duplicate
// launch — which Wails terminates via os.Exit without OnShutdown — never
// counts as a crash toward the Safe Mode threshold.
startupState, _ := tracker.Begin(version, launch.SafeMode)
trackerOwned := startupState.PID == os.Getpid()
// Keep WebKit acceleration enabled during normal Linux launches. If the
// startup tracker selects Safe Mode after a crash loop (or the user requests
// it explicitly), NVIDIA systems use the broader renderer fallback before
@@ -138,7 +125,6 @@ func main() {
configureWebKitRendererRecovery(launch.SafeMode)
app := NewApp()
app.remoteWindow = remoteLaunch
if trackerOwned {
app.startupTracker = tracker
}
@@ -147,17 +133,6 @@ func main() {
appMenu := app.createAppMenu()
dragAndDrop := &options.DragAndDrop{EnableFileDrop: true}
bindings := []any{app}
if remoteLaunch != nil {
if remoteLaunch.Title != "" {
title = remoteLaunch.Title
}
// Wails v2 has one native window per process. A remote shell must bypass
// the primary single-instance lock and must not expose local-only menus.
singleInstance = nil
appMenu = nil
dragAndDrop = &options.DragAndDrop{DisableWebViewDrop: true}
bindings = nil
}
// Restore saved window size, or fall back to the default.
width, height := 1240, 720
@@ -193,7 +168,6 @@ func main() {
AssetServer: &assetserver.Options{
Assets: assets,
Middleware: assetserver.ChainMiddleware(
app.remoteWindowAssetMiddleware(),
app.jsProfilingMiddleware(),
app.remoteMarkdownImageMiddleware(),
app.workspaceMediaMiddleware(),
@@ -255,18 +229,14 @@ func main() {
}
type desktopLaunchOptions struct {
SafeMode bool
RemoteWindowTicket string
SafeMode bool
}
func parseDesktopLaunchArgs(args []string) desktopLaunchOptions {
var out desktopLaunchOptions
for _, arg := range args {
switch {
case arg == "--safe-mode":
if arg == "--safe-mode" {
out.SafeMode = true
case strings.HasPrefix(arg, remoteWindowTicketArgPrefix):
out.RemoteWindowTicket = strings.TrimPrefix(arg, remoteWindowTicketArgPrefix)
}
}
return out
-10
View File
@@ -18,16 +18,6 @@ func TestParseDesktopLaunchArgsSafeMode(t *testing.T) {
}
}
func TestParseDesktopLaunchArgsRemoteWindowTicket(t *testing.T) {
got := parseDesktopLaunchArgs([]string{"--other", remoteWindowTicketArgPrefix + ".remote-window-123"})
if got.RemoteWindowTicket != ".remote-window-123" {
t.Fatalf("RemoteWindowTicket = %q", got.RemoteWindowTicket)
}
if got.SafeMode {
t.Fatal("remote window ticket unexpectedly enabled safe mode")
}
}
// TestMain isolates user config/state/cache dirs for the whole package. Without
// this, tests that persist desktop state, sessions, cache, or CLI-style config
// can leak into the developer's real Reasonix directories.
-7
View File
@@ -332,13 +332,6 @@ func ClassifyRemoteAskPassPrompt(prompt string) RemoteAskPassPromptKind {
}
}
// sanitizeRemoteAskPassPrompt strips terminal control sequences and control
// characters before any SSH-provided text reaches the Desktop UI. Printable
// fingerprint punctuation remains intact.
func sanitizeRemoteAskPassPrompt(prompt string) string {
return truncateRemoteAskPassPrompt(sanitizeRemoteAskPassPromptLimit(prompt, remoteAskPassMaxPromptBytes), 4096)
}
func sanitizeRemoteAskPassPromptLimit(prompt string, maxVisibleBytes int) string {
var builder strings.Builder
builder.Grow(min(len(prompt), maxVisibleBytes))
-2
View File
@@ -49,5 +49,3 @@ func (p *remoteSSHProcess) kill(cmd *exec.Cmd) bool {
})
return killed
}
func (*remoteSSHProcess) finish() {}
-223
View File
@@ -1,223 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strings"
"time"
"unicode"
"github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/internal/config"
)
const (
remoteWindowTicketArgPrefix = "--remote-window-ticket="
remoteWindowTicketPrefix = ".remote-window-"
remoteWindowTicketTTL = 2 * time.Minute
remoteWindowTicketMaxBytes = 16 * 1024
)
// remoteWindowLaunch is a one-shot handoff from the primary Reasonix process to
// a lightweight native window process. The URL contains the local tunnel token,
// so the descriptor lives in a mode-0600 file instead of the process arguments.
type remoteWindowLaunch struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
}
func remoteWindowTicketPath(ticket string) (string, error) {
if ticket == "" || filepath.Base(ticket) != ticket || !strings.HasPrefix(ticket, remoteWindowTicketPrefix) {
return "", fmt.Errorf("invalid remote window ticket")
}
dir := strings.TrimSpace(config.MemoryUserDir())
if dir == "" {
return "", fmt.Errorf("cannot resolve remote window state directory")
}
return filepath.Join(dir, ticket), nil
}
func writeRemoteWindowLaunch(launch remoteWindowLaunch) (string, error) {
if !isSafeRemoteWindowURL(launch.URL) {
return "", fmt.Errorf("remote window URL must use HTTP on loopback")
}
dir := config.MemoryUserDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create remote window state directory: %w", err)
}
f, err := os.CreateTemp(dir, remoteWindowTicketPrefix)
if err != nil {
return "", fmt.Errorf("create remote window ticket: %w", err)
}
path := f.Name()
remove := true
defer func() {
_ = f.Close()
if remove {
_ = os.Remove(path)
}
}()
if err := f.Chmod(0o600); err != nil {
return "", fmt.Errorf("secure remote window ticket: %w", err)
}
if err := json.NewEncoder(f).Encode(launch); err != nil {
return "", fmt.Errorf("write remote window ticket: %w", err)
}
if err := f.Sync(); err != nil {
return "", fmt.Errorf("sync remote window ticket: %w", err)
}
if err := f.Close(); err != nil {
return "", fmt.Errorf("close remote window ticket: %w", err)
}
remove = false
return filepath.Base(path), nil
}
func consumeRemoteWindowLaunch(ticket string) (*remoteWindowLaunch, error) {
path, err := remoteWindowTicketPath(ticket)
if err != nil {
return nil, err
}
info, err := os.Lstat(path)
if err != nil {
return nil, fmt.Errorf("inspect remote window ticket: %w", err)
}
defer os.Remove(path)
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("remote window ticket is not a regular file")
}
if info.Size() <= 0 || info.Size() > remoteWindowTicketMaxBytes {
return nil, fmt.Errorf("remote window ticket has an invalid size")
}
// Windows does not expose Unix owner/group permission bits through Stat;
// CreateTemp still creates the file for the current user, while ACLs remain
// governed by the private user state directory.
if goruntime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
return nil, fmt.Errorf("remote window ticket permissions are too broad")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read remote window ticket: %w", err)
}
var launch remoteWindowLaunch
if err := json.Unmarshal(data, &launch); err != nil {
return nil, fmt.Errorf("decode remote window ticket: %w", err)
}
if !isSafeRemoteWindowURL(launch.URL) {
return nil, fmt.Errorf("remote window URL must use HTTP on loopback")
}
return &launch, nil
}
func isSafeRemoteWindowURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "http" || u.Host == "" || u.User != nil {
return false
}
host := strings.TrimSpace(u.Hostname())
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func remoteWindowNavigationJS(raw string) (string, error) {
if !isSafeRemoteWindowURL(raw) {
return "", fmt.Errorf("remote window URL must use HTTP on loopback")
}
encoded, err := json.Marshal(raw)
if err != nil {
return "", err
}
return "window.location.replace(" + string(encoded) + ");", nil
}
func spawnRemoteWindow(launch remoteWindowLaunch) error {
ticket, err := writeRemoteWindowLaunch(launch)
if err != nil {
return err
}
path, _ := remoteWindowTicketPath(ticket)
executable, err := os.Executable()
if err != nil {
_ = os.Remove(path)
return fmt.Errorf("locate Reasonix executable: %w", err)
}
cmd := exec.Command(executable, remoteWindowTicketArgPrefix+ticket)
if err := cmd.Start(); err != nil {
_ = os.Remove(path)
return fmt.Errorf("start remote Reasonix window: %w", err)
}
// The child normally consumes the ticket immediately. This bounds any
// leftover token file if the child exits before reaching argument parsing.
time.AfterFunc(remoteWindowTicketTTL, func() { _ = os.Remove(path) })
if err := cmd.Process.Release(); err != nil {
return fmt.Errorf("release remote Reasonix window process: %w", err)
}
return nil
}
func remoteWindowTitle(hostID string) string {
hostID = strings.TrimSpace(strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, hostID))
runes := []rune(hostID)
if len(runes) > 80 {
hostID = string(runes[:80]) + "…"
}
if hostID == "" {
hostID = "Remote"
}
return "Reasonix [SSH: " + hostID + "]"
}
func (a *App) openRemoteWindow(rawURL, hostID string) error {
launch := remoteWindowLaunch{URL: rawURL, Title: remoteWindowTitle(hostID)}
if a.remoteWindowOpener != nil {
return a.remoteWindowOpener(launch)
}
return spawnRemoteWindow(launch)
}
func (a *App) remoteWindowAssetMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if a.remoteWindow == nil || (r.URL.Path != "/" && r.URL.Path != "/index.html") {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><style>html{background:#1a1a2e}</style></head><body></body></html>`))
})
}
}
func (a *App) domReadyRemoteWindow() {
if a.remoteWindow == nil {
return
}
if a.remoteWindowNavigated.CompareAndSwap(false, true) {
if a.remoteWindow.Title != "" {
runtime.WindowSetTitle(a.ctx, a.remoteWindow.Title)
}
if js, err := remoteWindowNavigationJS(a.remoteWindow.URL); err == nil {
runtime.WindowExecJS(a.ctx, js)
}
}
runtime.WindowCenter(a.ctx)
runtime.WindowShow(a.ctx)
}
-160
View File
@@ -1,160 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"reasonix/internal/config"
)
func TestRemoteWindowTicketRoundTripAndRemoval(t *testing.T) {
launch := remoteWindowLaunch{
URL: "http://127.0.0.1:54321/?token=secret-token",
Title: "Reasonix [SSH: box]",
}
ticket, err := writeRemoteWindowLaunch(launch)
if err != nil {
t.Fatal(err)
}
if strings.Contains(ticket, "secret-token") || filepath.Base(ticket) != ticket {
t.Fatalf("ticket leaked URL data or path: %q", ticket)
}
path, err := remoteWindowTicketPath(ticket)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("ticket permissions = %o, want 600", got)
}
}
got, err := consumeRemoteWindowLaunch(ticket)
if err != nil {
t.Fatal(err)
}
if *got != launch {
t.Fatalf("launch = %+v, want %+v", *got, launch)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("ticket was not removed after consumption: %v", err)
}
}
func TestRemoteWindowTicketRejectsUnsafeInputs(t *testing.T) {
for _, raw := range []string{
"https://127.0.0.1:5000/?token=x",
"http://example.com:5000/?token=x",
"file:///tmp/index.html",
"javascript:alert(1)",
} {
if _, err := writeRemoteWindowLaunch(remoteWindowLaunch{URL: raw}); err == nil {
t.Fatalf("unsafe URL accepted: %q", raw)
}
}
for _, ticket := range []string{"", "../.remote-window-x", "/tmp/.remote-window-x", "unrelated"} {
if _, err := remoteWindowTicketPath(ticket); err == nil {
t.Fatalf("unsafe ticket accepted: %q", ticket)
}
}
}
func TestConsumeRemoteWindowTicketRejectsBroadPermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not expose Unix permission bits through os.Stat")
}
dir := config.MemoryUserDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
ticket := remoteWindowTicketPrefix + "insecure"
path := filepath.Join(dir, ticket)
if err := os.WriteFile(path, []byte(`{"url":"http://127.0.0.1:5000/"}`), 0o644); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Remove(path) })
if _, err := consumeRemoteWindowLaunch(ticket); err == nil {
t.Fatal("ticket with broad permissions was accepted")
}
}
func TestConsumeRemoteWindowTicketRejectsOversizedDescriptor(t *testing.T) {
dir := config.MemoryUserDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
ticket := remoteWindowTicketPrefix + "oversized"
path := filepath.Join(dir, ticket)
if err := os.WriteFile(path, make([]byte, remoteWindowTicketMaxBytes+1), 0o600); err != nil {
t.Fatal(err)
}
if _, err := consumeRemoteWindowLaunch(ticket); err == nil {
t.Fatal("oversized ticket was accepted")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("rejected ticket was not removed: %v", err)
}
}
func TestRemoteWindowNavigationJSEscapesURL(t *testing.T) {
js, err := remoteWindowNavigationJS("http://127.0.0.1:5000/?token=x%22);alert(1)//")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(js, "window.location.replace(\"") || !strings.HasSuffix(js, "\");") {
t.Fatalf("unexpected navigation JS: %q", js)
}
if strings.Contains(js, "\");alert") {
t.Fatalf("URL escaped the JS string: %q", js)
}
}
func TestRemoteWindowLifecycleSkipsPrimaryRuntime(t *testing.T) {
a := NewApp()
a.remoteWindow = &remoteWindowLaunch{URL: "http://127.0.0.1:5000/"}
a.startup(context.Background())
if a.tabsRestored != nil {
t.Fatal("remote window initialized local tab restore")
}
if a.heartbeat != nil || a.tray != nil || a.remoteRuntime != nil {
t.Fatal("remote window initialized primary-process runtime")
}
if a.beforeClose(context.Background()) {
t.Fatal("remote window close was intercepted")
}
a.shutdown(context.Background())
}
func TestRemoteWindowAssetMiddlewareDoesNotLoadPrimaryFrontend(t *testing.T) {
a := &App{remoteWindow: &remoteWindowLaunch{URL: "http://127.0.0.1:5000/"}}
nextCalled := false
h := a.remoteWindowAssetMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
nextCalled = true
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if nextCalled {
t.Fatal("remote shell loaded the primary asset handler")
}
if strings.Contains(rec.Body.String(), "<script") {
t.Fatal("remote shell bootstrap unexpectedly contains frontend scripts")
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q", got)
}
}
func TestRemoteWindowTitleSanitizesHostLabel(t *testing.T) {
if got := remoteWindowTitle(" box\nprod "); got != "Reasonix [SSH: boxprod]" {
t.Fatalf("title = %q", got)
}
}
+3 -3
View File
@@ -21,7 +21,7 @@ const windowsWebView2StartupFallbackMessage = "The desktop interface did not bec
// isolation is the primary repair; this watchdog is the last-resort visible
// recovery path for policy-forced proxies and unrelated WebView2 failures.
func (a *App) startWindowsWebView2StartupFallback(ctx context.Context) {
if !shouldStartWindowsWebView2StartupFallback(goruntime.GOOS, a.remoteWindow != nil) {
if !shouldStartWindowsWebView2StartupFallback(goruntime.GOOS) {
return
}
go func() {
@@ -47,8 +47,8 @@ func (a *App) startWindowsWebView2StartupFallback(ctx context.Context) {
}()
}
func shouldStartWindowsWebView2StartupFallback(goos string, remoteWindow bool) bool {
return goos == "windows" && !remoteWindow
func shouldStartWindowsWebView2StartupFallback(goos string) bool {
return goos == "windows"
}
func awaitStartupFallback(ctx context.Context, timeout <-chan time.Time, ready func() bool) bool {
+7 -9
View File
@@ -8,19 +8,17 @@ import (
func TestWindowsWebView2StartupFallbackScope(t *testing.T) {
tests := []struct {
name string
goos string
remoteWindow bool
want bool
name string
goos string
want bool
}{
{name: "primary Windows window", goos: "windows", want: true},
{name: "remote Windows window", goos: "windows", remoteWindow: true, want: false},
{name: "non-Windows primary window", goos: "darwin", want: false},
{name: "Windows window", goos: "windows", want: true},
{name: "non-Windows window", goos: "darwin", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldStartWindowsWebView2StartupFallback(tt.goos, tt.remoteWindow); got != tt.want {
t.Fatalf("shouldStartWindowsWebView2StartupFallback(%q, %v) = %v, want %v", tt.goos, tt.remoteWindow, got, tt.want)
if got := shouldStartWindowsWebView2StartupFallback(tt.goos); got != tt.want {
t.Fatalf("shouldStartWindowsWebView2StartupFallback(%q) = %v, want %v", tt.goos, got, tt.want)
}
})
}