mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 01:03:51 +08:00
- 支持全部工作台标签与 AI 对话在多显示器中原生独立展示 - 修复拖拽指针捕获,恢复关闭、右键菜单和跨窗口释放识别 - 拆分子窗启动入口并按需加载语言与工作台内容 - 内容与 Monaco 完成可见绘制后再提交迁移,消除白屏和加载闪烁 - 加固结果编辑、宿主同步及关闭、超时和焦点竞态
202 lines
6.0 KiB
Go
202 lines
6.0 KiB
Go
package nativewindow
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/wailsapp/wails/v2"
|
|
"github.com/wailsapp/wails/v2/pkg/options"
|
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
|
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
// ChildOptions are intentionally small; the potentially large tab payload is
|
|
// kept in the parent registry and fetched through Bootstrap.
|
|
type ChildOptions struct {
|
|
ParentURL string
|
|
Token string
|
|
ID string
|
|
Kind string
|
|
Title string
|
|
X int
|
|
Y int
|
|
Width int
|
|
Height int
|
|
}
|
|
|
|
func ParseChildOptions(args []string) (ChildOptions, error) {
|
|
result := ChildOptions{
|
|
ParentURL: strings.TrimSpace(os.Getenv(envParentURL)),
|
|
Token: strings.TrimSpace(os.Getenv(envToken)),
|
|
ID: strings.TrimSpace(os.Getenv(envWindowID)),
|
|
Kind: strings.TrimSpace(os.Getenv(envKind)),
|
|
Title: strings.TrimSpace(os.Getenv(envTitle)),
|
|
X: environmentInt(envX, 0),
|
|
Y: environmentInt(envY, 0),
|
|
Width: environmentInt(envWidth, defaultWindowWidth),
|
|
Height: environmentInt(envHeight, defaultWindowHeight),
|
|
}
|
|
flags := flag.NewFlagSet("gonavi detached-window", flag.ContinueOnError)
|
|
flags.SetOutput(discardWriter{})
|
|
flags.StringVar(&result.ParentURL, "parent-url", result.ParentURL, "parent loopback bridge URL")
|
|
flags.StringVar(&result.Token, "token", result.Token, "parent bridge token")
|
|
flags.StringVar(&result.ID, "id", result.ID, "detached window ID")
|
|
flags.StringVar(&result.Kind, "kind", result.Kind, "detached window kind")
|
|
flags.StringVar(&result.Title, "title", result.Title, "native window title")
|
|
flags.IntVar(&result.X, "x", result.X, "virtual desktop x coordinate")
|
|
flags.IntVar(&result.Y, "y", result.Y, "virtual desktop y coordinate")
|
|
flags.IntVar(&result.Width, "width", result.Width, "window width")
|
|
flags.IntVar(&result.Height, "height", result.Height, "window height")
|
|
if err := flags.Parse(args); err != nil {
|
|
return ChildOptions{}, err
|
|
}
|
|
if flags.NArg() > 0 {
|
|
return ChildOptions{}, fmt.Errorf("unknown detached-window arguments: %s", strings.Join(flags.Args(), " "))
|
|
}
|
|
result.Kind = strings.TrimSpace(result.Kind)
|
|
if result.Kind == "" {
|
|
result.Kind = "workbench"
|
|
}
|
|
result.Title = strings.TrimSpace(result.Title)
|
|
if result.Title == "" {
|
|
result.Title = "GoNavi"
|
|
}
|
|
if result.Width <= 0 {
|
|
result.Width = defaultWindowWidth
|
|
}
|
|
if result.Height <= 0 {
|
|
result.Height = defaultWindowHeight
|
|
}
|
|
if err := validateChildOptions(result); err != nil {
|
|
return ChildOptions{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateChildOptions(options ChildOptions) error {
|
|
if strings.TrimSpace(options.Token) == "" || strings.TrimSpace(options.ID) == "" {
|
|
return fmt.Errorf("detached-window token and id are required")
|
|
}
|
|
parentURL, err := url.Parse(options.ParentURL)
|
|
if err != nil || parentURL.Scheme != "http" || parentURL.Host == "" || (parentURL.Path != "" && parentURL.Path != "/") || parentURL.RawQuery != "" || parentURL.User != nil {
|
|
return fmt.Errorf("detached-window parent URL must be an HTTP loopback origin")
|
|
}
|
|
host := parentURL.Hostname()
|
|
ip := net.ParseIP(host)
|
|
if ip == nil || !ip.IsLoopback() {
|
|
return fmt.Errorf("detached-window parent URL must use a loopback IP")
|
|
}
|
|
if len(options.ID) > 256 || strings.ContainsAny(options.ID, "\r\n\x00") {
|
|
return fmt.Errorf("detached-window id is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func environmentInt(name string, fallback int) int {
|
|
value, err := strconv.Atoi(strings.TrimSpace(os.Getenv(name)))
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
// RunChild starts one native system window in the current child process.
|
|
func RunChild(parentCtx context.Context, assetFS fs.FS, args []string) error {
|
|
if assetFS == nil {
|
|
return fmt.Errorf("web assets are unavailable")
|
|
}
|
|
childOptions, err := ParseChildOptions(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
parentURL, _ := url.Parse(childOptions.ParentURL)
|
|
proxy := httputil.NewSingleHostReverseProxy(parentURL)
|
|
originalDirector := proxy.Director
|
|
proxy.Director = func(request *http.Request) {
|
|
originalDirector(request)
|
|
request.Host = parentURL.Host
|
|
request.Header.Set(HeaderToken, childOptions.Token)
|
|
request.Header.Set(HeaderWindowID, childOptions.ID)
|
|
}
|
|
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
|
|
http.Error(w, fmt.Sprintf("detached parent is unavailable: %v", proxyErr), http.StatusBadGateway)
|
|
}
|
|
|
|
bridge := newBridge(childOptions)
|
|
control := newControl(bridge)
|
|
bridge.setReadyHandler(control.markFrontendReady)
|
|
minWidth, minHeight := detachedWindowMinimumSize(childOptions.Kind)
|
|
err = wails.Run(&options.App{
|
|
Title: childOptions.Title,
|
|
Width: childOptions.Width,
|
|
Height: childOptions.Height,
|
|
MinWidth: minWidth,
|
|
MinHeight: minHeight,
|
|
StartHidden: true,
|
|
Frameless: true,
|
|
AssetServer: &assetserver.Options{Handler: proxy},
|
|
BackgroundColour: &options.RGBA{
|
|
R: 255,
|
|
G: 255,
|
|
B: 255,
|
|
A: 255,
|
|
},
|
|
OnStartup: func(ctx context.Context) {
|
|
InitializeBridge(bridge, ctx)
|
|
InitializeControl(control, ctx)
|
|
if parentCtx != nil {
|
|
go func() {
|
|
select {
|
|
case <-parentCtx.Done():
|
|
wailsRuntime.Quit(ctx)
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
}
|
|
},
|
|
OnDomReady: func(ctx context.Context) {
|
|
setDetachedAccessoryActivationPolicy()
|
|
applyDetachedWindowBounds(
|
|
ctx,
|
|
childOptions.X,
|
|
childOptions.Y,
|
|
childOptions.Width,
|
|
childOptions.Height,
|
|
)
|
|
wailsRuntime.WindowSetTitle(ctx, childOptions.Title)
|
|
control.markDOMReady(ctx)
|
|
},
|
|
OnBeforeClose: func(ctx context.Context) bool {
|
|
return control.handleBeforeClose(ctx)
|
|
},
|
|
OnShutdown: func(context.Context) {
|
|
bridge.notifyClosing()
|
|
bridge.stop()
|
|
},
|
|
Bind: []interface{}{control, bridge},
|
|
})
|
|
return err
|
|
}
|
|
|
|
func detachedWindowMinimumSize(kind string) (width int, height int) {
|
|
if strings.TrimSpace(kind) == "ai-chat" {
|
|
return 360, 420
|
|
}
|
|
return 480, 320
|
|
}
|
|
|
|
type discardWriter struct{}
|
|
|
|
func (discardWriter) Write(payload []byte) (int, error) {
|
|
return len(payload), nil
|
|
}
|