diff --git a/README.md b/README.md index 530b1c2..648e5a0 100644 --- a/README.md +++ b/README.md @@ -69,11 +69,50 @@ see the [mygo README](mygo/README.md); with nothing but Docker, without changing anything on your machine. On Windows 11, download the `vfox-plus-windows` artifact instead, extract it -and run the one-command script in PowerShell (it self-signs with a local -certificate and installs; see [Windows packaging](shared/packaging/windows/README.md)): +and install the unsigned `.msixbundle` in an elevated PowerShell +(see [Windows packaging](shared/packaging/windows/README.md)): ```powershell -powershell -ExecutionPolicy Bypass -File Sign-Msix.ps1 +Add-AppxPackage -Path .\vfox-plus_.msixbundle -AllowUnsigned +``` + +The bundle is not signed yet; an officially signed bundle will be provided +later. + +### Use it in a browser + +`vfox-plus pair` connects a browser to the vfox-plus on this machine, in the +style of `opencode pair`. It works the same in bash on Ubuntu and in +PowerShell on Windows: + +```sh +vfox-plus pair +``` + +Open the link it prints. Links work once and expire after a few minutes. The +paired browser gets the full web interface at `/app`: installed SDKs with +install/use/uninstall, the plugin registry with add/update/remove, tasks +with live progress and logs, and the settings. + +To connect a browser on another machine, or to run vfox-plus in a container, +keep the pairing server up instead: + +```sh +vfox-plus serve --port 8080 +``` + +and open the link it prints, with its address replaced by the reachable one. +A paired browser mints links for more browsers with `GET /api/token`. +Non-loopback servers speak HTTPS: without `--tls-cert/--tls-key` a +self-signed certificate is generated next to the settings on first start, +and its fingerprint is printed for comparison in the browser. Bring your +own certificate to avoid the warning. The +published Docker image runs this server with no display needed; see +[Packaging](mygo/README.md#docker-image): + +```sh +docker build -f mygo/Dockerfile -t vfox-plus . +docker run --rm -p 8080:8080 vfox-plus ``` ## Repository layout diff --git a/README.zh-CN.md b/README.zh-CN.md index 7d68da2..a036051 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -61,12 +61,44 @@ vfox-plus `vfxtool sandbox package` 会把两个文件构建到 `dist/sandbox/`, 不改变本机任何东西。 -Windows 11 用户请下载 `vfox-plus-windows` 构件,解压后在 PowerShell 跑一键脚本 -(本地自签名证书并安装,见 +Windows 11 用户请下载 `vfox-plus-windows` 构件,解压后在管理员 PowerShell +安装未签名 `.msixbundle`(见 [Windows 打包](shared/packaging/windows/README.zh-CN.md)): ```powershell -powershell -ExecutionPolicy Bypass -File Sign-Msix.ps1 +Add-AppxPackage -Path .\vfox-plus_<版本>.msixbundle -AllowUnsigned +``` + +安装包目前尚未签名,日后会提供官方签名的安装包。 + +### 在浏览器中使用 + +`vfox-plus pair` 把浏览器与本机的 vfox-plus 配对,对标 `opencode pair`。 +Ubuntu 的 bash 与 Windows 的 PowerShell 用法相同: + +```sh +vfox-plus pair +``` + +在浏览器打开它输出的链接即可。链接一次性有效,几分钟后过期;配对后的浏览器 +在 `/app` 得到完整 Web 界面:已安装 SDK 的安装/使用/卸载、插件仓库的添 +加/更新/删除、带实时进度与日志的任务、以及设置。 + +另一台机器的浏览器,或容器中运行的 vfox-plus,用常驻服务代替: + +```sh +vfox-plus serve --port 8080 +``` + +打开它输出的链接,把地址换成可达地址即可。已配对的浏览器用 `GET /api/token` +为更多浏览器增发链接。非回环服务走 HTTPS:不传 `--tls-cert/--tls-key` 时首次 +启动在设置目录旁生成自签名证书并打印指纹,请在浏览器核对;自备证书可消除 +警告。发布 Docker 镜像直接跑该服务,无需显示;见 +[打包](mygo/README.zh-CN.md#docker-镜像): + +```sh +docker build -f mygo/Dockerfile -t vfox-plus . +docker run --rm -p 8080:8080 vfox-plus ``` ## 仓库布局 diff --git a/docs/architecture.md b/docs/architecture.md index c8f69cf..e58a733 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -194,3 +194,16 @@ possible. The process runner and the vfox client are the seams for that: a remote implementation of the process runner (or of the client) can forward the same commands to an agent on the machine, and the state and UI layers stay unchanged. The CLI contract then becomes the protocol. + +`vfox-plus pair` (package `mygo/pair`) is the first step: a pairing server +with one-time links, in the style of `opencode pair`. `vfox-plus serve` keeps +that server up for containers and remote instances, with expiring sessions +and mintable links, and the published Docker image ([`mygo/Dockerfile`](../mygo/Dockerfile)) +runs it with no display needed. Non-loopback servers speak HTTPS: without +`--tls-cert/--tls-key` a self-signed certificate is generated next to the +settings on first start, its fingerprint is printed for comparison in the +browser, and the session cookie is Secure. A paired browser gets the full web interface +at `/app`: installed SDKs with install/use/uninstall, the plugin registry +with add/update/remove, tasks with live progress and logs over server-sent +events, and the settings. An Android client on the same protocol is still to +come. diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md index fd214b2..13436ff 100644 --- a/docs/zh-CN/architecture.md +++ b/docs/zh-CN/architecture.md @@ -160,3 +160,11 @@ Windows `.msix` 与 `.msixbundle` 无额外运行时依赖:Go 可执行文件 连接跑 vfox 机器的 Web UI 与 Android 客户端仍可能。进程运行器与 vfox 客户端是接缝: 远端实现转发同样命令到机器上的 agent,状态与 UI 层不变。CLI 契约届时成为协议。 + +`vfox-plus pair`(`mygo/pair` 包)是第一步:配对服务,一次性链接,对标 +`opencode pair`。`vfox-plus serve` 为容器与远端实例常驻该服务,会话可过期、 +链接可增发;发布 Docker 镜像([`mygo/Dockerfile`](../mygo/Dockerfile))无需显示 +直接跑它。非回环服务走 HTTPS:不传 `--tls-cert/--tls-key` 时首次启动在设置目录 +旁生成自签名证书,在浏览器核对打印的指纹,会话 cookie 为 Secure。配对后的浏览器在 `/app` 得到完整 Web 界面:已安装 SDK 的安 +装/使用/卸载、插件仓库的添加/更新/删除、带实时进度与日志的任务、以及设置。 +同协议的 Android 客户端仍待后续。 diff --git a/mygo/Dockerfile b/mygo/Dockerfile new file mode 100644 index 0000000..b9968a0 --- /dev/null +++ b/mygo/Dockerfile @@ -0,0 +1,109 @@ +# syntax=docker/dockerfile:1 +# Copyright 2026 Han Li and contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# The published vfox-plus image: a minimal Ubuntu runtime with vfox and a +# `vfox-plus serve` that browsers pair with. It needs no display server: the +# pairing server is plain Go without a UI toolkit. +# +# Build from the repository root (the build reads mygo/ and shared/): +# +# docker build -f mygo/Dockerfile -t vfox-plus . +# +# Run it with the port mapped, then open the pairing link it prints, with +# 0.0.0.0 replaced by the reachable address. The link is https: a +# self-signed certificate is generated in the container on first start, +# and its fingerprint is printed for comparison in the browser. +# +# docker run --rm -p 8080:8080 vfox-plus +# +# A browser on another machine pairs the same way, over the network the +# container is reachable on. Prefer an encrypted tunnel outside trusted +# networks. A data volume keeps the installed SDKs between runs: +# +# docker run --rm -p 8080:8080 -v vfox-plus-data:/home/vfoxplus vfox-plus +# +# The toolchain versions default to mygo/versions.env; keep them in sync. +# Only linux/amd64 is built here: vfox publishes per-architecture archives, +# and the URL below names the x86_64 one. + +ARG GO_VERSION=1.27.3 +ARG GO_SHA256=65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40 +ARG VFOX_VERSION=1.0.13 +ARG VFOX_SHA256=7ad0a4c82c8173f09393e09abecc3e27468253bab5fe53ffbf58f2b12b3fc768 + +FROM ubuntu:26.04 AS build +ARG GO_VERSION +ARG GO_SHA256 +ARG VFOX_VERSION +ARG VFOX_SHA256 + +# Go, verified against the published checksum. +RUN <_.msix` and a `vfox-plus_.msixbundle`, with the manifest, logos and bundle layout of -[`shared/packaging/windows`](../shared/packaging/windows). Users install with the -one-command [`Sign-Msix.ps1`](../shared/packaging/windows/Sign-Msix.ps1) script -(self-signed certificate, no third party needed); to distribute widely, sign it -with a trusted certificate. CI cross-compiles and packages on Linux, validates with +[`shared/packaging/windows`](../shared/packaging/windows). Users install the +unsigned bundle in an elevated PowerShell with +`Add-AppxPackage -AllowUnsigned`; the bundle is not signed yet and an +officially signed bundle will be provided later. CI cross-compiles and +packages on Linux, validates with `vfxtool validate-msix`, and installs/uninstalls the bundle on Windows Server 2025 (x64) and Windows 11 on ARM (arm64). +### Docker image + +[`Dockerfile`](Dockerfile) builds the published image from the repository +root: a minimal Ubuntu runtime with vfox and `vfox-plus serve`, needing no +display server. The toolchain versions default to [`versions.env`](versions.env). + +```sh +docker build -f mygo/Dockerfile -t vfox-plus . +docker run --rm -p 8080:8080 vfox-plus +``` + +Open the pairing link it prints, with `0.0.0.0` replaced by the reachable +address. The link is `https`: the container generates a self-signed +certificate on first start and prints its fingerprint for comparison in +the browser. A `-v vfox-plus-data:/home/vfoxplus` volume keeps the installed +SDKs between runs. A browser on another machine pairs the same way, over +the network the container is reachable on. + ## Memory Measured with `vfxtool sandbox measure`: Ubuntu 26.04 in Docker, Xvfb, a diff --git a/mygo/README.zh-CN.md b/mygo/README.zh-CN.md index 4a4c64a..e580eae 100644 --- a/mygo/README.zh-CN.md +++ b/mygo/README.zh-CN.md @@ -18,7 +18,8 @@ Material 3 Expressive 观感的部件;其元素与无样式部件之上全部 | [`ui/mcu/`](ui/mcu) | 配色:Material 颜色工具的 Go 移植(HCT、CAM16、tonal spot) | | [`ui/shapes/`](ui/shapes) | 形状:`androidx.graphics.shapes` 圆角多边形及其形变的 Go 移植 | | [`assets/`](assets) | 二进制嵌入物:图标字体、翻译与许可证(构建时生成) | -| [`main.go`](main.go) | 组装模型与窗口 | +| [`pair/`](pair) | `vfox-plus pair` / `vfox-plus serve`:浏览器配对到本机(一次性链接),或为容器与远端实例常驻服务;配对后的浏览器得到完整 Web 界面(`/app`:SDK、仓库、带实时日志的任务、设置) | +| [`main.go`](main.go) | 组装模型与窗口;分发 `pair` 子命令 | 分层见[架构](../docs/zh-CN/architecture.md):`core` 不知道其它层,`state` 只知道 `core`,`ui` 在上。`core`、`state`、`ui/mcu` 与 `ui/shapes` 是无 UI 工具包的纯 Go, @@ -176,10 +177,25 @@ Windows 11 上,`vfxtool build-msix` 把交叉编译的可执行文件 `vfox-plus_<版本>_<架构>.msix` 与 `vfox-plus_<版本>.msixbundle`,含 [`shared/packaging/windows`](../shared/packaging/windows) 的清单、徽标与 bundle 布局。 -用户用一键 [`Sign-Msix.ps1`](../shared/packaging/windows/Sign-Msix.ps1) 脚本安装 -(自签名证书,无需第三方);广泛分发请用受信证书签名。 +用户在管理员 PowerShell 用 `Add-AppxPackage -AllowUnsigned` 安装未签名 bundle; +安装包目前尚未签名,日后会提供官方签名的安装包。 CI 在 Linux 交叉编译打包并用 `vfxtool validate-msix` 校验,在 Windows Server 2025(x64)与 ARM 版 Windows 11(arm64)上校验并安装/卸载 bundle。 +### Docker 镜像 + +[`Dockerfile`](Dockerfile) 从仓库根构建发布镜像:最小 Ubuntu 运行时,含 vfox 与 +`vfox-plus serve`,无需显示服务。工具链版本默认取 +[`versions.env`](versions.env)。 + +```sh +docker build -f mygo/Dockerfile -t vfox-plus . +docker run --rm -p 8080:8080 vfox-plus +``` + +打开它输出的配对链接,把 `0.0.0.0` 换成可达地址。链接是 `https`:容器首次启动 +生成自签名证书并打印指纹,请在浏览器核对。`-v vfox-plus-data:/home/vfoxplus` +卷在多次运行之间保留已安装的 SDK。另一台机器的浏览器在容器可达的网络上同样配对。 + ## 内存 `vfxtool sandbox measure` 测量:Docker 内 Ubuntu 26.04,Xvfb,1280×800 窗口,单进程, diff --git a/mygo/main.go b/mygo/main.go index 94a3207..0dd2189 100644 --- a/mygo/main.go +++ b/mygo/main.go @@ -16,17 +16,37 @@ package main import ( + "fmt" "log" "os" "strings" "github.com/version-fox/vfox-plus/mygo/core" "github.com/version-fox/vfox-plus/mygo/core/settings" + "github.com/version-fox/vfox-plus/mygo/pair" "github.com/version-fox/vfox-plus/mygo/state" "github.com/version-fox/vfox-plus/mygo/ui" ) func main() { + // `vfox-plus pair` pairs a browser with this machine, `vfox-plus serve` + // keeps the pairing server up for containers and remote instances; + // anything else starts the desktop application. + if len(os.Args) > 1 { + switch os.Args[1] { + case "pair": + os.Exit(pair.Main(os.Args[2:])) + case "serve": + os.Exit(pair.ServeMain(os.Args[2:])) + case "help", "--help", "-h": + fmt.Println("Usage: vfox-plus [pair|serve]") + fmt.Println() + fmt.Println(" (no arguments) Start the desktop application.") + fmt.Println(" pair Pair a browser with this machine over loopback.") + fmt.Println(" serve Keep the pairing server up for containers and remote instances.") + return + } + } environment := make(map[string]string) for _, entry := range os.Environ() { if key, value, ok := strings.Cut(entry, "="); ok { diff --git a/mygo/pair/command.go b/mygo/pair/command.go new file mode 100644 index 0000000..26735e2 --- /dev/null +++ b/mygo/pair/command.go @@ -0,0 +1,81 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pair + +import ( + "errors" + "flag" + "fmt" + "os" + "os/signal" + "time" +) + +// Main runs `vfox-plus pair`. It returns the process exit code. The output +// stays English on purpose: it must survive any terminal encoding on bash +// and on PowerShell, and it follows the wording of `opencode pair`. +func Main(args []string) int { + flags := flag.NewFlagSet("pair", flag.ContinueOnError) + bind := flags.String("bind", DefaultBind, "interface to listen on (loopback only)") + port := flags.Int("port", 0, "port to listen on (0 picks a free one)") + tokenTTL := flags.Duration("token-ttl", DefaultTokenTTL, "how long the pairing link works") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintln(os.Stderr, "Usage: vfox-plus pair [--bind 127.0.0.1] [--port 0] [--token-ttl 5m]") + return 2 + } + snapshot := LocalSnapshot() + // Probe now, so that a missing vfox fails fast with a clear message + // instead of an empty pairing page. + _ = snapshot() + server, err := NewServer(Options{Bind: *bind, Port: *port, TokenTTL: *tokenTTL, Snapshot: snapshot}) + if err != nil { + fmt.Fprintln(os.Stderr, "pair:", err) + return 1 + } + defer func() { _ = server.Close() }() + _, url, err := server.NewToken() + if err != nil { + fmt.Fprintln(os.Stderr, "pair:", err) + return 1 + } + fmt.Println() + fmt.Println(" Open a link to connect. Links work once and expire in", formatTTL(server.ttl)+".") + fmt.Println() + fmt.Println(" " + url) + fmt.Println() + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt) + <-signals + fmt.Println() + return 0 +} + +// formatTTL renders a duration the way the pairing message words it. +func formatTTL(ttl time.Duration) string { + if ttl >= time.Minute && ttl%time.Minute == 0 { + minutes := int(ttl.Minutes()) + if minutes == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", minutes) + } + return ttl.String() +} diff --git a/mygo/pair/doc.go b/mygo/pair/doc.go new file mode 100644 index 0000000..77d5489 --- /dev/null +++ b/mygo/pair/doc.go @@ -0,0 +1,28 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package pair connects a browser to a vfox-plus instance. +// +// `vfox-plus pair` starts a loopback HTTP server and prints a one-time link +// like `http://127.0.0.1:PORT/auth/connect/TOKEN`, in the style of +// `opencode pair`. Opening the link pairs the browser: the token works once +// and expires after a few minutes, and the server issues a session cookie +// for the paired browser. +// +// A paired browser gets the full web interface at `/app` (a single embedded +// page, no external requests): installed SDKs, the plugin registry, tasks +// with live progress and logs over server-sent events, and the settings. +// `vfox-plus serve` keeps the same server up for containers and remote +// instances, with expiring sessions and mintable links. +package pair diff --git a/mygo/pair/pair.go b/mygo/pair/pair.go new file mode 100644 index 0000000..b759415 --- /dev/null +++ b/mygo/pair/pair.go @@ -0,0 +1,468 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pair + +import ( + "crypto/rand" + "crypto/tls" + "encoding/hex" + "encoding/json" + "fmt" + "html/template" + "net" + "net/http" + "strings" + "sync" + "time" + + core "github.com/version-fox/vfox-plus/mygo/core" + "github.com/version-fox/vfox-plus/mygo/state" +) + +// DefaultTokenTTL is how long a pairing link works, like the five minutes of +// `opencode pair`. +const DefaultTokenTTL = 5 * time.Minute + +// DefaultBind is the only interface the server listens on: the IPv4 +// loopback, so a pairing link never leaves the machine. +const DefaultBind = "127.0.0.1" + +// SdkSummary is one installed SDK on the pairing page. +type SdkSummary struct { + // Name is the plugin name, for example "nodejs". + Name string `json:"name"` + // Current is the version in use, if any. + Current string `json:"current"` + // HasCurrent reports whether Current is set. + HasCurrent bool `json:"hasCurrent"` + // Versions are the installed versions. + Versions []string `json:"versions"` +} + +// Snapshot is the read-only state the paired browser sees. +type Snapshot struct { + // HasVfox reports whether vfox answered `--version`. + HasVfox bool `json:"hasVfox"` + // VfoxVersion is the version of vfox, when it answered. + VfoxVersion string `json:"vfoxVersion,omitempty"` + // VfoxError is why vfox is not available, when it is not. + VfoxError string `json:"vfoxError,omitempty"` + // SDKs are the installed SDKs, when vfox answered `list`. + SDKs []SdkSummary `json:"sdks"` +} + +// Summarize converts installed SDKs to the shape the page shows. +func Summarize(installed []core.InstalledSdk) []SdkSummary { + summaries := make([]SdkSummary, 0, len(installed)) + for _, sdk := range installed { + summaries = append(summaries, SdkSummary{ + Name: sdk.Name, + Current: sdk.Current, + HasCurrent: sdk.HasCurrent, + Versions: append([]string(nil), sdk.Versions...), + }) + } + return summaries +} + +// DefaultSessionTTL is how long a paired browser stays paired on a +// persistent server. Zero means the session lives as long as the server. +const DefaultSessionTTL = 24 * time.Hour + +// Options configures a Server. +type Options struct { + // Bind is the interface to listen on. Empty means DefaultBind. Only + // loopback addresses are accepted unless AllowNonLoopback is set, for a + // server behind a port mapping or on a trusted network. + Bind string + // AllowNonLoopback permits listening on a non-loopback interface. Pair + // links then travel the network: only use it where the network is + // trusted, and prefer pairing over an encrypted tunnel. + AllowNonLoopback bool + // Port is the port to listen on. Zero picks a free one. + Port int + // TokenTTL is how long a pairing link works. Zero means DefaultTokenTTL. + TokenTTL time.Duration + // SessionTTL is how long a paired browser stays paired. Zero means the + // session lives as long as the server. Expired sessions answer 401 and + // must pair again. + SessionTTL time.Duration + // Snapshot returns the read-only state the paired browser sees. + Snapshot func() Snapshot + // Model is the application state behind the web interface. When nil, + // the server only pairs and serves the read-only snapshot: the + // `vfox-plus pair` mode. `vfox-plus serve` hands it a started AppModel. + Model *state.AppModel + // TLSCertFile and TLSKeyFile switch the server to HTTPS. Empty means + // plain HTTP, which only loopback binds use. + TLSCertFile string + TLSKeyFile string + // Fingerprint is the SHA-256 fingerprint of the TLS certificate, shown + // on the intro and pairing pages for comparison with the browser. + Fingerprint string +} + +// Server is a pairing server. Create it with NewServer, hand out links with +// NewToken, and close it with Close. +type Server struct { + listener net.Listener + httpServer *http.Server + handler http.Handler + mu sync.Mutex + tokens map[string]time.Time + sessions map[string]time.Time + ttl time.Duration + sttl time.Duration + snapshot func() Snapshot + model *state.AppModel + tls bool + // fingerprint is the SHA-256 fingerprint of the TLS certificate, + // shown on the intro and pairing pages for comparison. + fingerprint string +} + +// NewServer starts a pairing server, by default on the loopback interface. +func NewServer(options Options) (*Server, error) { + bind := options.Bind + if bind == "" { + bind = DefaultBind + } + if !options.AllowNonLoopback { + if ip := net.ParseIP(bind); ip == nil || !ip.IsLoopback() { + return nil, fmt.Errorf("pair: refusing to listen on non-loopback %q", bind) + } + } + ttl := options.TokenTTL + if ttl <= 0 { + ttl = DefaultTokenTTL + } + snapshot := options.Snapshot + if snapshot == nil { + snapshot = func() Snapshot { return Snapshot{} } + } + listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", bind, options.Port)) + if err != nil { + return nil, err + } + server := &Server{ + listener: listener, + tokens: make(map[string]time.Time), + sessions: make(map[string]time.Time), + ttl: ttl, + sttl: options.SessionTTL, + snapshot: snapshot, + model: options.Model, + fingerprint: options.Fingerprint, + } + if options.TLSCertFile != "" || options.TLSKeyFile != "" { + if options.TLSCertFile == "" || options.TLSKeyFile == "" { + _ = listener.Close() + return nil, fmt.Errorf("pair: a TLS certificate and key go together") + } + cert, err := tls.LoadX509KeyPair(options.TLSCertFile, options.TLSKeyFile) + if err != nil { + _ = listener.Close() + return nil, err + } + server.tls = true + listener = tls.NewListener(listener, &tls.Config{Certificates: []tls.Certificate{cert}}) + server.listener = listener + } + mux := http.NewServeMux() + mux.HandleFunc("/", server.serveIntro) + mux.HandleFunc("/auth/connect/", server.serveConnect) + mux.HandleFunc("/api/status", server.serveStatus) + mux.HandleFunc("/api/token", server.serveToken) + mux.HandleFunc("/app", server.serveApp) + mux.HandleFunc("/api/inventory", server.serveInventory) + mux.HandleFunc("/api/sdks/", server.serveSdks) + mux.HandleFunc("/api/tasks", server.serveTasks) + mux.HandleFunc("/api/tasks/", server.serveTask) + mux.HandleFunc("/api/plugins", server.servePlugins) + mux.HandleFunc("/api/settings", server.serveSettings) + mux.HandleFunc("/api/config", server.serveConfig) + mux.HandleFunc("/api/events", server.serveEvents) + server.handler = mux + // ReadHeaderTimeout only: the server answers big logs and keeps + // server-sent events open, so the body and the writes stay unbounded. + server.httpServer = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + go func() { + _ = server.httpServer.Serve(listener) + }() + return server, nil +} + +// Addr is the address the server listens on. +func (s *Server) Addr() net.Addr { + return s.listener.Addr() +} + +// scheme is the URL scheme of the server. +func (s *Server) scheme() string { + if s.tls { + return "https" + } + return "http" +} + +// BaseURL is the URL of the server, without a trailing slash. +func (s *Server) BaseURL() string { + host, port, err := net.SplitHostPort(s.listener.Addr().String()) + if err != nil { + return "" + } + if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") { + host = "[" + host + "]" + } + return s.scheme() + "://" + host + ":" + port +} + +// DisplayBaseURL is the URL to show to the user. When the server listens on +// a wildcard address, browsers cannot open it as is, so it renders +// 0.0.0.0 as a placeholder for the reachable address. +func (s *Server) DisplayBaseURL() string { + host, port, err := net.SplitHostPort(s.listener.Addr().String()) + if err != nil { + return s.BaseURL() + } + if ip := net.ParseIP(strings.Trim(host, "[]")); host == "" || (ip != nil && ip.IsUnspecified()) { + return s.scheme() + "://0.0.0.0:" + port + } + return s.BaseURL() +} + +// NewToken creates a one-time pairing link. +func (s *Server) NewToken() (token string, url string, err error) { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", "", err + } + token = hex.EncodeToString(bytes[:]) + s.mu.Lock() + s.tokens[token] = time.Now().Add(s.ttl) + s.mu.Unlock() + return token, s.BaseURL() + "/auth/connect/" + token, nil +} + +// Close stops the server. +func (s *Server) Close() error { + return s.httpServer.Close() +} + +// takeToken consumes token. It reports whether the token was valid, unused +// and unexpired. +func (s *Server) takeToken(token string) bool { + s.mu.Lock() + defer s.mu.Unlock() + expiry, ok := s.tokens[token] + if !ok { + return false + } + delete(s.tokens, token) + return time.Now().Before(expiry) +} + +// newSession issues a session cookie value for a paired browser. +func (s *Server) newSession() string { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + // rand.Read only fails when the operating system is out of entropy; + // fall back to a timestamped value rather than refusing to pair. + return fmt.Sprintf("fallback-%d", time.Now().UnixNano()) + } + session := hex.EncodeToString(bytes[:]) + s.mu.Lock() + s.sessions[session] = time.Now() + s.mu.Unlock() + return session +} + +// hasSession reports whether the request carries a paired session that has +// not expired yet. Expired sessions are dropped. +func (s *Server) hasSession(request *http.Request) bool { + cookie, err := request.Cookie("vfox-plus-session") + if err != nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + created, ok := s.sessions[cookie.Value] + if !ok { + return false + } + if s.sttl > 0 && time.Since(created) > s.sttl { + delete(s.sessions, cookie.Value) + return false + } + return true +} + +// publicURL renders a pairing path with the host the client used, so that a +// browser behind a port mapping gets a link it can open. It falls back to +// the listening address when the request carries no usable host. +func (s *Server) publicURL(request *http.Request, token string) string { + host := request.Host + if host == "" { + return s.BaseURL() + "/auth/connect/" + token + } + if _, _, err := net.SplitHostPort(host); err != nil { + // No port in the Host header: the client reached us on the + // listening port. + host += s.portSuffix() + } + return s.scheme() + "://" + host + "/auth/connect/" + token +} + +// portSuffix renders the listening port for hosts without one. +func (s *Server) portSuffix() string { + _, port, err := net.SplitHostPort(s.listener.Addr().String()) + if err != nil || port == "" || port == "80" { + return "" + } + return ":" + port +} + +// serveIntro answers without a token. It never mentions tokens, so that +// scanners learn nothing about pairing links. +func (s *Server) serveIntro(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/" { + http.NotFound(writer, request) + return + } + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = introTemplate.Execute(writer, map[string]string{"Fingerprint": s.fingerprint}) +} + +// serveConnect pairs a browser holding a one-time link. +func (s *Server) serveConnect(writer http.ResponseWriter, request *http.Request) { + token := strings.TrimPrefix(request.URL.Path, "/auth/connect/") + if token == "" || strings.Contains(token, "/") || !s.takeToken(token) { + http.Error(writer, "This pairing link is invalid, already used or expired. Run `vfox-plus pair` again for a new one.", http.StatusForbidden) + return + } + session := s.newSession() + // Secure unconditionally: loopback origins (localhost, 127.0.0.1) are + // trustworthy, so browsers send Secure cookies back over plain HTTP + // there; every non-loopback bind serves HTTPS, where Secure is required. + // Static analysis (CodeQL, gosec) only accepts the literal. + http.SetCookie(writer, &http.Cookie{ + Name: "vfox-plus-session", + Value: session, + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = connectTemplate.Execute(writer, connectData{ + Snapshot: s.snapshot(), + Chinese: prefersChinese(request.Header.Get("Accept-Language")), + Fingerprint: s.fingerprint, + }) +} + +// serveStatus answers the read-only API for paired browsers. +func (s *Server) serveStatus(writer http.ResponseWriter, request *http.Request) { + if !s.hasSession(request) { + http.Error(writer, "Not paired. Open a `vfox-plus pair` link first.", http.StatusUnauthorized) + return + } + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(writer).Encode(s.snapshot()) +} + +// serveToken mints another one-time pairing link for an already paired +// browser, so that a second browser can join without returning to the +// terminal. It renders the link with the host the caller used, which is the +// reachable address behind a port mapping. +func (s *Server) serveToken(writer http.ResponseWriter, request *http.Request) { + if !s.hasSession(request) { + http.Error(writer, "Not paired. Open a `vfox-plus pair` link first.", http.StatusUnauthorized) + return + } + token, _, err := s.NewToken() + if err != nil { + http.Error(writer, "Cannot mint a link right now.", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(writer).Encode(map[string]string{"url": s.publicURL(request, token)}) +} + +// prefersChinese reports whether the browser prefers Simplified Chinese. +func prefersChinese(header string) bool { + for _, part := range strings.Split(header, ",") { + language := strings.ToLower(strings.TrimSpace(strings.SplitN(part, ";", 2)[0])) + if strings.HasPrefix(language, "zh") { + return true + } + if language == "" { + continue + } + if language == "*" { + continue + } + return false + } + return false +} + +type connectData struct { + Snapshot + Chinese bool + Fingerprint string +} + +var introTemplate = template.Must(template.New("intro").Parse(` + +vfox-plus pair + +

vfox-plus pair

+

This page pairs a browser with the vfox-plus instance on this machine.

+

Run vfox-plus pair in a terminal (bash on Ubuntu, PowerShell on Windows) and open the link it prints. Links work once and expire after a few minutes.

+

运行 vfox-plus pair(Ubuntu 用 bash,Windows 用 PowerShell),在浏览器打开它输出的一次性链接即可配对。链接一次性有效,几分钟后过期。

+{{if .Fingerprint}}

Certificate fingerprint: {{.Fingerprint}}

+

证书指纹:{{.Fingerprint}}(信任自签名证书前请核对)。

{{end}} + +`)) + +var connectTemplate = template.Must(template.New("connect").Parse(` + +vfox-plus {{if .Chinese}}已配对{{else}}paired{{end}} + +

vfox-plus {{if .Chinese}}已配对{{else}}paired{{end}}

+{{if .Chinese}} +

此浏览器已与本机的 vfox-plus 配对。配对链接一次性有效,请勿分享。

+{{if .Fingerprint}}

证书指纹:{{.Fingerprint}}。

{{end}} +{{else}} +

This browser is paired with vfox-plus on this machine. Pairing links work once; do not share them.

+{{if .Fingerprint}}

Certificate fingerprint: {{.Fingerprint}}.

{{end}} +{{end}} +

vfox

+{{if .HasVfox}} +

{{.VfoxVersion}}

+

{{if .Chinese}}已安装{{else}}Installed{{end}}

+{{if .SDKs}} +
    +{{range .SDKs}}
  • {{.Name}}{{if .HasCurrent}} — {{.Current}}{{end}} ({{range $i, $v := .Versions}}{{if $i}}, {{end}}{{$v}}{{end}})
  • {{end}} +
+{{else}} +

{{if .Chinese}}暂无已安装的 SDK。{{else}}No SDKs installed yet.{{end}}

+{{end}} +{{else}} +

{{if .Chinese}}未找到可用的 vfox:{{.VfoxError}}。请先安装 vfox(https://vfox.dev/zh-hans/guides/quick-start.html),再重新配对。{{else}}No working vfox: {{.VfoxError}}. Install vfox first (https://vfox.dev/guides/quick-start.html), then pair again.{{end}}

+{{end}} + +`)) diff --git a/mygo/pair/pair_test.go b/mygo/pair/pair_test.go new file mode 100644 index 0000000..6697f41 --- /dev/null +++ b/mygo/pair/pair_test.go @@ -0,0 +1,253 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pair_test + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + core "github.com/version-fox/vfox-plus/mygo/core" + "github.com/version-fox/vfox-plus/mygo/pair" +) + +func testSnapshot() pair.Snapshot { + return pair.Snapshot{ + HasVfox: true, + VfoxVersion: "1.0.13", + SDKs: []pair.SdkSummary{ + {Name: "nodejs", Current: "24.11.1", HasCurrent: true, Versions: []string{"24.11.1", "22.21.0"}}, + }, + } +} + +func testServer(t *testing.T, snapshot pair.Snapshot) *pair.Server { + t.Helper() + server, err := pair.NewServer(pair.Options{Snapshot: func() pair.Snapshot { return snapshot }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close() }) + return server +} + +func get(t *testing.T, url, cookie string) (int, string, []*http.Cookie) { + t.Helper() + request, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + if cookie != "" { + request.AddCookie(&http.Cookie{Name: "vfox-plus-session", Value: cookie, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer func() { _ = response.Body.Close() }() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return response.StatusCode, string(body), response.Cookies() +} + +func TestPairLinkWorksOnce(t *testing.T) { + server := testServer(t, testSnapshot()) + _, url, err := server.NewToken() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(url, server.BaseURL()+"/auth/connect/") { + t.Fatalf("got %q", url) + } + status, body, cookies := get(t, url, "") + if status != http.StatusOK { + t.Fatalf("got %d", status) + } + if !strings.Contains(body, "1.0.13") || !strings.Contains(body, "nodejs") { + t.Errorf("the page must show the instance: %q", body) + } + var session string + for _, cookie := range cookies { + if cookie.Name == "vfox-plus-session" { + session = cookie.Value + } + } + if session == "" { + t.Fatal("pairing must issue a session") + } + // The link is single-use. + if status, _, _ := get(t, url, ""); status != http.StatusForbidden { + t.Errorf("a used link must be rejected, got %d", status) + } + // The session unlocks the read-only API. + status, body, _ = get(t, server.BaseURL()+"/api/status", session) + if status != http.StatusOK { + t.Fatalf("got %d", status) + } + if !strings.Contains(body, "24.11.1") { + t.Errorf("got %q", body) + } + // Without the session the API refuses. + if status, _, _ := get(t, server.BaseURL()+"/api/status", ""); status != http.StatusUnauthorized { + t.Errorf("got %d", status) + } +} + +func TestPairLinkExpires(t *testing.T) { + server, err := pair.NewServer(pair.Options{ + TokenTTL: 10 * time.Millisecond, + Snapshot: func() pair.Snapshot { return testSnapshot() }, + }) + if err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close() }() + _, url, err := server.NewToken() + if err != nil { + t.Fatal(err) + } + time.Sleep(50 * time.Millisecond) + if status, _, _ := get(t, url, ""); status != http.StatusForbidden { + t.Errorf("an expired link must be rejected, got %d", status) + } +} + +func TestPairListensOnLoopbackOnly(t *testing.T) { + server := testServer(t, testSnapshot()) + if _, err := pair.NewServer(pair.Options{Bind: "0.0.0.0"}); err == nil { + t.Error("a non-loopback bind must be refused") + } + if open, err := pair.NewServer(pair.Options{Bind: "0.0.0.0", AllowNonLoopback: true}); err != nil { + t.Skipf("no non-loopback interface in this sandbox: %v", err) + } else { + _ = open.Close() + } + addr := server.Addr().String() + if !strings.HasPrefix(addr, "127.0.0.1:") { + t.Errorf("got %q", addr) + } + if got := server.DisplayBaseURL(); got != server.BaseURL() { + t.Errorf("a loopback server shows its own address, got %q", got) + } +} + +func TestDisplayBaseURLMasksWildcards(t *testing.T) { + server, err := pair.NewServer(pair.Options{ + Bind: "0.0.0.0", + AllowNonLoopback: true, + Snapshot: func() pair.Snapshot { return testSnapshot() }, + }) + if err != nil { + t.Skipf("no non-loopback interface in this sandbox: %v", err) + } + defer func() { _ = server.Close() }() + got := server.DisplayBaseURL() + if !strings.HasPrefix(got, "http://0.0.0.0:") { + t.Errorf("a wildcard server must show a replaceable placeholder, got %q", got) + } +} + +func TestPairPageExplainsAMissingVfox(t *testing.T) { + server := testServer(t, pair.Snapshot{VfoxError: "executable file not found"}) + _, url, err := server.NewToken() + if err != nil { + t.Fatal(err) + } + status, body, _ := get(t, url, "") + if status != http.StatusOK { + t.Fatalf("got %d", status) + } + if !strings.Contains(body, "vfox.dev") { + t.Errorf("the page must point at the vfox installation guide: %q", body) + } +} + +func TestSummarizeKeepsInstalledVersions(t *testing.T) { + got := pair.Summarize([]core.InstalledSdk{ + {Name: "nodejs", Current: "24.11.1", HasCurrent: true, Versions: []string{"24.11.1"}}, + }) + if len(got) != 1 || got[0].Name != "nodejs" || !got[0].HasCurrent || len(got[0].Versions) != 1 { + t.Errorf("got %#v", got) + } +} + +func pairSession(t *testing.T, server *pair.Server) string { + t.Helper() + _, url, err := server.NewToken() + if err != nil { + t.Fatal(err) + } + _, _, cookies := get(t, url, "") + for _, cookie := range cookies { + if cookie.Name == "vfox-plus-session" { + return cookie.Value + } + } + t.Fatal("pairing must issue a session") + return "" +} + +func TestSessionExpires(t *testing.T) { + server, err := pair.NewServer(pair.Options{ + SessionTTL: 20 * time.Millisecond, + Snapshot: func() pair.Snapshot { return testSnapshot() }, + }) + if err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close() }() + session := pairSession(t, server) + if status, _, _ := get(t, server.BaseURL()+"/api/status", session); status != http.StatusOK { + t.Fatalf("got %d", status) + } + time.Sleep(50 * time.Millisecond) + if status, _, _ := get(t, server.BaseURL()+"/api/status", session); status != http.StatusUnauthorized { + t.Errorf("an expired session must be rejected, got %d", status) + } +} + +func TestPairedBrowserMintsLinks(t *testing.T) { + server := testServer(t, testSnapshot()) + session := pairSession(t, server) + // Minting needs the session. + if status, _, _ := get(t, server.BaseURL()+"/api/token", ""); status != http.StatusUnauthorized { + t.Fatalf("got %d", status) + } + status, body, _ := get(t, server.BaseURL()+"/api/token", session) + if status != http.StatusOK { + t.Fatalf("got %d", status) + } + var minted struct { + URL string `json:"url"` + } + if err := json.Unmarshal([]byte(body), &minted); err != nil || minted.URL == "" { + t.Fatalf("got %q, %v", body, err) + } + if !strings.HasPrefix(minted.URL, server.BaseURL()+"/auth/connect/") { + t.Errorf("got %q", minted.URL) + } + // The minted link pairs once. + if status, _, _ := get(t, minted.URL, ""); status != http.StatusOK { + t.Errorf("got %d", status) + } + if status, _, _ := get(t, minted.URL, ""); status != http.StatusForbidden { + t.Errorf("got %d", status) + } +} diff --git a/mygo/pair/serve.go b/mygo/pair/serve.go new file mode 100644 index 0000000..7f4734c --- /dev/null +++ b/mygo/pair/serve.go @@ -0,0 +1,131 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pair + +import ( + "errors" + "flag" + "fmt" + "net" + "os" + "os/signal" + "strings" +) + +// ServeMain runs `vfox-plus serve`: the pairing server as a persistent +// service for containers and remote instances. It prints one pairing link +// at start; paired browsers mint more with `GET /api/token`. The output +// stays English on purpose: it must survive any terminal encoding on bash +// and on PowerShell. +func ServeMain(args []string) int { + flags := flag.NewFlagSet("serve", flag.ContinueOnError) + bind := flags.String("bind", DefaultBind, "interface to listen on") + port := flags.Int("port", 0, "port to listen on (0 picks a free one)") + tokenTTL := flags.Duration("token-ttl", DefaultTokenTTL, "how long a pairing link works") + sessionTTL := flags.Duration("session-ttl", DefaultSessionTTL, "how long a paired browser stays paired (0 lives as long as the server)") + tlsCert := flags.String("tls-cert", "", "TLS certificate file (enables HTTPS)") + tlsKey := flags.String("tls-key", "", "TLS key file (enables HTTPS)") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintln(os.Stderr, "Usage: vfox-plus serve [--bind 127.0.0.1] [--port 0] [--token-ttl 5m] [--session-ttl 24h] [--tls-cert cert.pem --tls-key key.pem]") + return 2 + } + if (*tlsCert == "") != (*tlsKey == "") { + fmt.Fprintln(os.Stderr, "serve: --tls-cert and --tls-key go together") + return 2 + } + loopback := true + if ip := net.ParseIP(*bind); ip == nil || !ip.IsLoopback() { + loopback = false + } + certFile, keyFile, fingerprint := *tlsCert, *tlsKey, "" + selfSigned := false + switch { + case certFile != "": + print, err := CertFingerprint(certFile) + if err != nil { + fmt.Fprintln(os.Stderr, "serve:", err) + return 1 + } + fingerprint = print + case !loopback: + // Non-loopback binds always speak TLS: a self-signed pair is + // generated next to the settings unless the user brings one. + hosts := LocalAddresses() + if parsed := net.ParseIP(strings.Trim(*bind, "[]")); parsed != nil && !parsed.IsUnspecified() { + hosts = append(hosts, *bind) + } + generated, generatedKey, print, err := EnsureServerCert(LocalSettingsDir(), hosts) + if err != nil { + fmt.Fprintln(os.Stderr, "serve:", err) + return 1 + } + certFile, keyFile, fingerprint, selfSigned = generated, generatedKey, print, true + } + snapshot := LocalSnapshot() + _ = snapshot() + model := NewLocalModel() + defer model.Dispose() + server, err := NewServer(Options{ + Bind: *bind, + AllowNonLoopback: true, + Port: *port, + TokenTTL: *tokenTTL, + SessionTTL: *sessionTTL, + Snapshot: func() Snapshot { return SnapshotOf(model.Cli) }, + Model: model, + TLSCertFile: certFile, + TLSKeyFile: keyFile, + Fingerprint: fingerprint, + }) + if err != nil { + fmt.Fprintln(os.Stderr, "serve:", err) + return 1 + } + defer func() { _ = server.Close() }() + token, _, err := server.NewToken() + if err != nil { + fmt.Fprintln(os.Stderr, "serve:", err) + return 1 + } + url := server.DisplayBaseURL() + "/auth/connect/" + token + fmt.Println() + fmt.Println(" Serving vfox-plus on " + server.DisplayBaseURL()) + if !loopback { + fmt.Println(" Listening on non-loopback " + *bind + "; replace the address below") + fmt.Println(" with the reachable one.") + if selfSigned { + fmt.Println(" Using a self-signed certificate: verify the fingerprint below") + fmt.Println(" in the browser before trusting it.") + } + } + if fingerprint != "" { + fmt.Println(" Certificate fingerprint: " + fingerprint) + } + fmt.Println(" Open a link to connect. Links work once and expire in " + formatTTL(server.ttl) + ".") + fmt.Println() + fmt.Println(" " + url) + fmt.Println() + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt) + <-signals + fmt.Println() + return 0 +} diff --git a/mygo/pair/snapshot.go b/mygo/pair/snapshot.go new file mode 100644 index 0000000..ba8da1e --- /dev/null +++ b/mygo/pair/snapshot.go @@ -0,0 +1,101 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pair + +import ( + "os" + "path/filepath" + "strings" + + core "github.com/version-fox/vfox-plus/mygo/core" + "github.com/version-fox/vfox-plus/mygo/core/settings" + "github.com/version-fox/vfox-plus/mygo/core/vfox" + "github.com/version-fox/vfox-plus/mygo/state" +) + +// localEnv returns the process environment and the home directory. +func localEnv() (map[string]string, string) { + environment := make(map[string]string) + for _, entry := range os.Environ() { + if key, value, ok := strings.Cut(entry, "="); ok { + environment[key] = value + } + } + home, _ := os.UserHomeDir() + return environment, home +} + +// LocalSettingsDir is the directory of the GUI settings. +func LocalSettingsDir() string { + environment, home := localEnv() + return filepath.Dir(settings.SettingsPath(environment, home)) +} + +// localLaunchConfig points a vfox client at this machine: the user's +// settings, the process environment and the located vfox. +func localLaunchConfig() (vfox.VfoxLaunchConfig, map[string]string) { + environment, _ := localEnv() + home, _ := os.UserHomeDir() + store := settings.JSONFileSettingsStore{Path: settings.SettingsPath(environment, home)} + stored := store.Load() + config := vfox.DefaultLaunchConfig() + config.BaseEnvironment = environment + config.ExtraEnvironment = stored.ExtraEnvironment + if stored.VfoxExecutable != "" { + config.Executable = stored.VfoxExecutable + } else { + config.Executable = vfox.NewVfoxLocator(environment, nil).Locate() + } + if config.Executable == "" { + config.Executable = vfox.VfoxExecutableName + } + return config, environment +} + +// SnapshotOf reads the state of client: the vfox version and the installed +// SDKs. A vfox that is missing or broken yields a snapshot that says so +// instead of an error, so that the pairing page can explain it. +func SnapshotOf(client *vfox.Client) Snapshot { + version, err := client.Version() + if err != nil { + return Snapshot{VfoxError: err.Error()} + } + installed, err := client.InstalledSdks() + if err != nil { + return Snapshot{HasVfox: true, VfoxVersion: version, VfoxError: err.Error()} + } + return Snapshot{HasVfox: true, VfoxVersion: version, SDKs: Summarize(installed)} +} + +// LocalSnapshot returns the read-only state of this machine. +func LocalSnapshot() func() Snapshot { + config, _ := localLaunchConfig() + client := vfox.NewClient(core.OsProcessRunner{}, config) + return func() Snapshot { return SnapshotOf(client) } +} + +// NewLocalModel builds the application state of this machine and starts it, +// like the desktop application does. The caller owns it and must Dispose it. +func NewLocalModel() *state.AppModel { + _, environment := localLaunchConfig() + home, _ := os.UserHomeDir() + model := state.NewAppModel(state.AppModelOptions{ + Runner: core.OsProcessRunner{}, + SettingsStore: settings.JSONFileSettingsStore{Path: settings.SettingsPath(environment, home)}, + Environment: environment, + }) + model.Start() + return model +} diff --git a/mygo/pair/tlscert.go b/mygo/pair/tlscert.go new file mode 100644 index 0000000..74488c9 --- /dev/null +++ b/mygo/pair/tlscert.go @@ -0,0 +1,218 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pair + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +// certFileName and keyFileName are the self-signed server certificate of +// `vfox-plus serve`, next to the GUI settings. +const ( + certFileName = "serve-cert.pem" + keyFileName = "serve-key.pem" +) + +// certValidity is how long a generated certificate lasts. Rotation is +// manual: deleting the files mints a fresh pair on the next start. +const certValidity = 10 * 365 * 24 * time.Hour + +// EnsureServerCert returns the certificate and key in dir, generating a +// self-signed pair when absent, expired or mismatched. hosts names the +// subject alternative names besides loopback: DNS names and IP addresses, +// for example the bind address and the machine's interface addresses. +// It also returns the SHA-256 fingerprint of the certificate, in the +// OpenSSL colon-separated form the browser shows for comparison. +func EnsureServerCert(dir string, hosts []string) (certFile, keyFile, fingerprint string, err error) { + certFile = filepath.Join(dir, certFileName) + keyFile = filepath.Join(dir, keyFileName) + if fingerprint, ok := validPair(certFile, keyFile); ok { + return certFile, keyFile, fingerprint, nil + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", "", err + } + certDER, keyDER, err := generateCert(hosts) + if err != nil { + return "", "", "", err + } + if err := writeFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil { + return "", "", "", err + } + if err := writeFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), 0o644); err != nil { + return "", "", "", err + } + return certFile, keyFile, Fingerprint(certDER), nil +} + +// validPair reports whether certFile and keyFile hold a matching, +// unexpired pair, with its fingerprint. +func validPair(certFile, keyFile string) (string, bool) { + pair, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil || len(pair.Certificate) == 0 { + return "", false + } + cert, err := x509.ParseCertificate(pair.Certificate[0]) + if err != nil { + return "", false + } + now := time.Now() + if now.Before(cert.NotBefore.Add(-time.Hour)) || now.After(cert.NotAfter) { + return "", false + } + return Fingerprint(pair.Certificate[0]), true +} + +// generateCert mints a self-signed ECDSA certificate for hosts. +func generateCert(hosts []string) (certDER, keyDER []byte, err error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, err + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "vfox-plus serve"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(certValidity), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + seen := map[string]bool{} + addIP := func(ip net.IP) { + if ip == nil || seen[ip.String()] { + return + } + seen[ip.String()] = true + template.IPAddresses = append(template.IPAddresses, ip) + } + addDNS := func(name string) { + name = strings.ToLower(name) + if name == "" || seen[name] { + return + } + seen[name] = true + template.DNSNames = append(template.DNSNames, name) + } + addIP(net.ParseIP("127.0.0.1")) + addIP(net.ParseIP("::1")) + addDNS("localhost") + for _, host := range hosts { + if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil { + addIP(ip) + } else if host != "" { + addDNS(host) + } + } + certDER, err = x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return nil, nil, err + } + keyDER, err = x509.MarshalECPrivateKey(key) + if err != nil { + return nil, nil, err + } + return certDER, keyDER, nil +} + +func writeFile(path string, content []byte, perm os.FileMode) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) //nolint:gosec // Permissions are set explicitly here. + if err != nil { + return err + } + if _, err := file.Write(content); err != nil { + _ = file.Close() + return err + } + return file.Close() +} + +// Fingerprint renders the SHA-256 fingerprint of a DER certificate the way +// browsers show it: upper-case hex pairs separated by colons. +func Fingerprint(certDER []byte) string { + sum := sha256.Sum256(certDER) + hexed := strings.ToUpper(hex.EncodeToString(sum[:])) + pairs := make([]string, 0, len(hexed)/2) + for i := 0; i < len(hexed); i += 2 { + pairs = append(pairs, hexed[i:i+2]) + } + return strings.Join(pairs, ":") +} + +// CertFingerprint returns the SHA-256 fingerprint of the first certificate +// in certFile. +func CertFingerprint(certFile string) (string, error) { + content, err := os.ReadFile(certFile) //nolint:gosec // The user chose the certificate file. + if err != nil { + return "", err + } + block, _ := pem.Decode(content) + if block == nil || block.Type != "CERTIFICATE" { + return "", fmt.Errorf("pair: %s holds no certificate", certFile) + } + return Fingerprint(block.Bytes), nil +} + +// LocalAddresses lists the machine's non-loopback IP addresses, for the +// certificate of a server behind a port mapping. +func LocalAddresses() []string { + var addresses []string + interfaces, err := net.Interfaces() + if err != nil { + return nil + } + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + var ip net.IP + switch typed := addr.(type) { + case *net.IPNet: + ip = typed.IP + case *net.IPAddr: + ip = typed.IP + } + if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + addresses = append(addresses, ip.String()) + } + } + return addresses +} diff --git a/mygo/pair/tlscert_test.go b/mygo/pair/tlscert_test.go new file mode 100644 index 0000000..2a72f4c --- /dev/null +++ b/mygo/pair/tlscert_test.go @@ -0,0 +1,165 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pair_test + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/version-fox/vfox-plus/mygo/pair" +) + +func TestEnsureServerCert(t *testing.T) { + dir := t.TempDir() + certFile, keyFile, fingerprint, err := pair.EnsureServerCert(dir, []string{"example.com", "192.0.2.1"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(fingerprint, ":") || len(strings.ReplaceAll(fingerprint, ":", "")) != 64 { + t.Errorf("got %q", fingerprint) + } + content, err := os.ReadFile(certFile) //nolint:gosec // The test reads the file it just wrote. + if err != nil { + t.Fatal(err) + } + block, _ := pem.Decode(content) + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatal(err) + } + if cert.NotAfter.Sub(cert.NotBefore) < 9*365*24*time.Hour { + t.Errorf("validity too short: %v to %v", cert.NotBefore, cert.NotAfter) + } + var sans []string + for _, ip := range cert.IPAddresses { + sans = append(sans, ip.String()) + } + sans = append(sans, cert.DNSNames...) + for _, want := range []string{"127.0.0.1", "localhost", "example.com", "192.0.2.1"} { + found := false + for _, san := range sans { + if san == want { + found = true + } + } + if !found { + t.Errorf("missing SAN %q in %q", want, sans) + } + } + info, err := os.Stat(keyFile) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("the key must be owner-only, got %o", info.Mode().Perm()) + } + // A second call reuses the pair instead of minting a new one. + before, err := os.ReadFile(certFile) //nolint:gosec // The test reads the file it just wrote. + if err != nil { + t.Fatal(err) + } + _, _, again, err := pair.EnsureServerCert(dir, []string{"other.example"}) + if err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(certFile) //nolint:gosec // The test reads the file it just wrote. + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) || again != fingerprint { + t.Error("a valid pair must be reused") + } + if _, err := pair.CertFingerprint(certFile); err != nil { + t.Fatal(err) + } + if _, err := pair.CertFingerprint(filepath.Join(dir, "missing.pem")); err == nil { + t.Error("a missing certificate must fail") + } +} + +func tlsClient() *http.Client { + return &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // Tests trust the self-signed certificate they just minted. + }} +} + +func TestTLSServerPairsOverHTTPS(t *testing.T) { + dir := t.TempDir() + certFile, keyFile, fingerprint, err := pair.EnsureServerCert(dir, nil) + if err != nil { + t.Fatal(err) + } + server, err := pair.NewServer(pair.Options{ + AllowNonLoopback: true, + TLSCertFile: certFile, + TLSKeyFile: keyFile, + Fingerprint: fingerprint, + Snapshot: func() pair.Snapshot { return testSnapshot() }, + }) + if err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close() }() + if !strings.HasPrefix(server.BaseURL(), "https://") { + t.Errorf("got %q", server.BaseURL()) + } + client := tlsClient() + _, url, err := server.NewToken() + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + t.Fatalf("got %d", response.StatusCode) + } + secure := false + for _, cookie := range response.Cookies() { + if cookie.Name == "vfox-plus-session" && cookie.Secure { + secure = true + } + } + if !secure { + t.Error("the session cookie must be Secure over TLS") + } +} + +func TestServerCookieIsSecure(t *testing.T) { + server := testServer(t, testSnapshot()) + _, url, err := server.NewToken() + if err != nil { + t.Fatal(err) + } + _, _, cookies := get(t, url, "") + for _, cookie := range cookies { + if cookie.Name == "vfox-plus-session" && !cookie.Secure { + t.Error("the session cookie must be Secure, on loopback and over TLS alike") + } + } +} diff --git a/mygo/pair/web.go b/mygo/pair/web.go new file mode 100644 index 0000000..f87a4c2 --- /dev/null +++ b/mygo/pair/web.go @@ -0,0 +1,236 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pair + +import ( + _ "embed" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/version-fox/vfox-plus/mygo/state" +) + +//go:embed webapp.html +var webappHTML []byte + +// Web UI routes served by Server when it owns an AppModel: +// +// /app the single-page interface (paired browsers only) +// /api/inventory installed SDKs, registry plugins and vfox config +// /api/sdks//info plugin metadata +// /api/sdks//versions installable versions +// /api/tasks list tasks (GET) and start one (POST) +// /api/tasks/finished forget finished tasks (DELETE) +// /api/tasks/ one task with its log tail (GET) +// /api/tasks//cancel cancel a task (POST) +// /api/tasks//retry run a task again (POST) +// /api/plugins add a plugin from a URL or an upload (POST) +// /api/settings the GUI settings (GET and PUT) +// /api/events server-sent events: task, inventory and vfox +// +// Every route except the intro and the pairing links needs a paired session. +// State-changing routes additionally need a same-origin Origin or Referer +// header when the browser sends one, so that a foreign page cannot drive a +// paired browser with cross-site requests. Requests without either header, +// like curl, are allowed through: the session cookie is the credential. +func (s *Server) serveApp(writer http.ResponseWriter, request *http.Request) { + if !s.hasSession(request) { + http.Redirect(writer, request, "/", http.StatusFound) + return + } + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + writer.Header().Set("Cache-Control", "no-store") + _, _ = writer.Write(webappHTML) +} + +func (s *Server) requireModel(writer http.ResponseWriter) *state.AppModel { + if s.model == nil { + http.Error(writer, "This server runs without an application model.", http.StatusServiceUnavailable) + return nil + } + return s.model +} + +// requireSession rejects unpaired browsers. +func (s *Server) requireSession(writer http.ResponseWriter, request *http.Request) bool { + if !s.hasSession(request) { + http.Error(writer, "Not paired. Open a pairing link first.", http.StatusUnauthorized) + return false + } + return true +} + +// requireSameOrigin rejects cross-site requests that carry an Origin or a +// Referer header from elsewhere. Same-origin fetches from the web interface +// always carry the server itself. +func (s *Server) requireSameOrigin(writer http.ResponseWriter, request *http.Request) bool { + origin := request.Header.Get("Origin") + if origin == "" { + referer := request.Header.Get("Referer") + if referer == "" { + return true + } + if sameHost(referer, request.Host) { + return true + } + } else if sameHost(origin, request.Host) { + return true + } + http.Error(writer, "Cross-site requests are not allowed.", http.StatusForbidden) + return false +} + +// sameHost reports whether rawURL names host. +func sameHost(rawURL, host string) bool { + withoutScheme := rawURL + if i := strings.Index(withoutScheme, "://"); i >= 0 { + withoutScheme = withoutScheme[i+3:] + } + if i := strings.IndexByte(withoutScheme, '/'); i >= 0 { + withoutScheme = withoutScheme[:i] + } + return strings.EqualFold(withoutScheme, host) +} + +// writeJSON answers with value. +func writeJSON(writer http.ResponseWriter, value interface{}) { + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(writer).Encode(value) +} + +// writeVfoxError answers with the message of a vfox failure. +func writeVfoxError(writer http.ResponseWriter, err error) { + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(writer).Encode(map[string]string{"error": err.Error()}) +} + +// serveEvents streams task, inventory and vfox changes to the web interface. +// Each message is `event: \ndata: {}\n\n`; the payload is empty and +// the browser refetches what changed. A heartbeat comment keeps proxies +// from closing idle connections. +func (s *Server) serveEvents(writer http.ResponseWriter, request *http.Request) { + if !s.requireSession(writer, request) { + return + } + model := s.requireModel(writer) + if model == nil { + return + } + flusher, ok := writer.(http.Flusher) + if !ok { + http.Error(writer, "Streaming is not supported.", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Connection", "keep-alive") + writer.Header().Set("X-Accel-Buffering", "no") + + notify := make(chan string, 8) + emit := func(kind string) { + select { + case notify <- kind: + default: + } + } + var stops []state.Unsubscribe + defer func() { + for _, stop := range stops { + stop() + } + }() + stops = append(stops, + model.Tasks.AddListener(func() { emit("task") }), + model.Installed.AddListener(func() { emit("inventory") }), + model.Registry.AddListener(func() { emit("inventory") }), + model.VfoxConfig.AddListener(func() { emit("inventory") }), + model.Vfox.AddListener(func() { emit("vfox") }), + ) + var taskStops []state.Unsubscribe + cleanupTasks := func() { + for _, stop := range taskStops { + stop() + } + taskStops = nil + } + defer cleanupTasks() + watchTasks := func() { + cleanupTasks() + for _, task := range model.Tasks.Tasks() { + task := task + taskStops = append(taskStops, task.AddListener(func() { emit("task") })) + } + } + // Rebuild the per-task listeners whenever anything changed, so that new + // tasks are watched and finished ones are released. + watchTasks() + + send := func(kind string) bool { + _, err := fmt.Fprintf(writer, "event: %s\ndata: {}\n\n", kind) + if err != nil { + return false + } + flusher.Flush() + return true + } + if !send("hello") { + return + } + heartbeat := time.NewTicker(25 * time.Second) + defer heartbeat.Stop() + pending := map[string]bool{} + flush := func() bool { + for kind := range pending { + delete(pending, kind) + if kind == "task" { + watchTasks() + } + if !send(kind) { + return false + } + } + return true + } + for { + select { + case <-request.Context().Done(): + return + case kind := <-notify: + pending[kind] = true + // Drain bursts, like download progress lines, into one update. + for { + select { + case kind := <-notify: + pending[kind] = true + default: + goto drained + } + } + drained: + if !flush() { + return + } + case <-heartbeat.C: + if _, err := fmt.Fprintf(writer, ": ping\n\n"); err != nil { + return + } + flusher.Flush() + } + } +} diff --git a/mygo/pair/webapi.go b/mygo/pair/webapi.go new file mode 100644 index 0000000..985a9dc --- /dev/null +++ b/mygo/pair/webapi.go @@ -0,0 +1,631 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pair + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + core "github.com/version-fox/vfox-plus/mygo/core" + "github.com/version-fox/vfox-plus/mygo/core/settings" + "github.com/version-fox/vfox-plus/mygo/core/vfox" + "github.com/version-fox/vfox-plus/mygo/state" +) + +// loadTimeout bounds how long the web API waits for vfox. The desktop +// interface waits indefinitely; a browser fetch must not hang forever when +// vfox or the network stalls. +const loadTimeout = 2 * time.Minute + +// awaitLoad waits for done or the timeout. +func awaitLoad(done <-chan struct{}) bool { + timer := time.NewTimer(loadTimeout) + defer timer.Stop() + select { + case <-done: + return true + case <-timer.C: + return false + } +} + +// VfoxDTO is the availability of vfox for the web interface. +type VfoxDTO struct { + Availability string `json:"availability"` + Version string `json:"version,omitempty"` + Error string `json:"error,omitempty"` +} + +// TaskDTO is one state-changing task for the web interface. +type TaskDTO struct { + ID int `json:"id"` + Operation string `json:"operation"` + Sdk string `json:"sdk,omitempty"` + Version string `json:"version,omitempty"` + CommandLine string `json:"commandLine"` + Status string `json:"status"` + Progress *float64 `json:"progress,omitempty"` + CreatedAt string `json:"createdAt"` + StartedAt string `json:"startedAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` + ExitCode *int `json:"exitCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + LastLine string `json:"lastLine,omitempty"` + Log []string `json:"log"` + Dropped int `json:"droppedLines"` +} + +// taskDTO converts task, keeping the newest tailLines log lines. +func taskDTO(task *state.VfoxTask, tailLines int) TaskDTO { + dto := TaskDTO{ + ID: task.ID, + Operation: string(task.Command.Operation), + Sdk: task.Command.Sdk, + Version: task.Command.Version, + CommandLine: task.Command.CommandLine(), + Status: string(task.Status()), + CreatedAt: task.CreatedAt.Format(time.RFC3339), + Dropped: task.DroppedLines(), + } + if progress, ok := task.Progress(); ok { + dto.Progress = &progress + } + if at, ok := task.StartedAt(); ok { + dto.StartedAt = at.Format(time.RFC3339) + } + if at, ok := task.FinishedAt(); ok { + dto.FinishedAt = at.Format(time.RFC3339) + } + if code, ok := task.ExitCode(); ok { + dto.ExitCode = &code + } + if message, ok := task.ErrorMessage(); ok { + dto.ErrorMessage = message + } + if last, ok := task.LastLine(); ok { + dto.LastLine = last + } + log := task.Log() + if tailLines > 0 && len(log) > tailLines { + log = log[len(log)-tailLines:] + } + dto.Log = log + if dto.Log == nil { + dto.Log = []string{} + } + return dto +} + +// vfoxDTO reports the availability of vfox. +func vfoxDTO(model *state.AppModel) VfoxDTO { + dto := VfoxDTO{Availability: string(model.Vfox.Availability())} + if version, ok := model.Vfox.Version(); ok { + dto.Version = version + } + if err := model.Vfox.Error(); err != nil { + dto.Error = err.Error() + } + return dto +} + +// serveInventory answers the installed SDKs, the registry and the vfox +// configuration in one fetch. Each section loads on demand and carries its +// own loading and error state, like the desktop interface. +func (s *Server) serveInventory(writer http.ResponseWriter, request *http.Request) { + if !s.requireSession(writer, request) { + return + } + model := s.requireModel(writer) + if model == nil { + return + } + if request.Method != http.MethodGet { + http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) + return + } + installedDone := model.Installed.EnsureLoaded() + registryDone := model.Registry.EnsureLoaded() + configDone := model.VfoxConfig.EnsureLoaded() + ok := awaitLoad(installedDone) && awaitLoad(registryDone) && awaitLoad(configDone) + installed, _ := model.Installed.Value() + installedErr := model.Installed.Error() + registry, _ := model.Registry.Value() + registryErr := model.Registry.Error() + config, _ := model.VfoxConfig.Value() + configErr := model.VfoxConfig.Error() + type section[T any] struct { + Loading bool `json:"loading"` + Error string `json:"error,omitempty"` + Value T `json:"value"` + } + errText := func(err error) string { + if err == nil { + return "" + } + return err.Error() + } + writeJSON(writer, map[string]interface{}{ + "vfox": vfoxDTO(model), + "installed": section[[]core.InstalledSdk]{Loading: model.Installed.IsLoading(), Error: errText(installedErr), Value: orEmpty(installed)}, + "registry": section[[]core.AvailablePlugin]{Loading: model.Registry.IsLoading(), Error: errText(registryErr), Value: orEmpty(registry)}, + "config": section[[]core.ConfigEntry]{Loading: model.VfoxConfig.IsLoading(), Error: errText(configErr), Value: orEmpty(config)}, + "complete": ok, + }) +} + +func orEmpty[T any](value []T) []T { + if value == nil { + return []T{} + } + return value +} + +// serveSdks answers plugin metadata and installable versions: +// /api/sdks//info and /api/sdks//versions. +func (s *Server) serveSdks(writer http.ResponseWriter, request *http.Request) { + if !s.requireSession(writer, request) { + return + } + model := s.requireModel(writer) + if model == nil { + return + } + if request.Method != http.MethodGet { + http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) + return + } + rest := strings.TrimPrefix(request.URL.Path, "/api/sdks/") + name, kind, _ := strings.Cut(rest, "/") + name = strings.TrimSpace(name) + if name == "" || strings.Contains(name, "/") { + http.Error(writer, "No such SDK.", http.StatusNotFound) + return + } + detail := model.SdkDetail(name) + defer detail.Dispose() + switch kind { + case "info": + if !awaitLoad(detail.Info.EnsureLoaded()) { + http.Error(writer, "Loading the plugin metadata timed out.", http.StatusGatewayTimeout) + return + } + info, ok := detail.Info.Value() + if !ok { + if err := detail.Info.Error(); err != nil { + writeVfoxError(writer, err) + return + } + http.Error(writer, "No such plugin.", http.StatusNotFound) + return + } + writeJSON(writer, info) + case "versions": + if !awaitLoad(detail.Versions.EnsureLoaded()) { + http.Error(writer, "Searching versions timed out.", http.StatusGatewayTimeout) + return + } + versions, _ := detail.Versions.Value() + if err := detail.Versions.Error(); err != nil { + writeVfoxError(writer, err) + return + } + writeJSON(writer, orEmpty(versions)) + default: + http.Error(writer, "No such endpoint.", http.StatusNotFound) + } +} + +// taskRequest starts a state-changing task from the web interface. Project +// scopes are rejected: the browser cannot name a directory on the machine +// that runs vfox, so only the global scope is offered there. +type taskRequest struct { + Operation string `json:"operation"` + Sdk string `json:"sdk"` + Version string `json:"version"` + Scope string `json:"scope"` + Source string `json:"source"` + Alias string `json:"alias"` + Name string `json:"name"` +} + +// serveTasks lists tasks and starts new ones. +func (s *Server) serveTasks(writer http.ResponseWriter, request *http.Request) { + if !s.requireSession(writer, request) { + return + } + model := s.requireModel(writer) + if model == nil { + return + } + switch request.Method { + case http.MethodGet: + tasks := make([]TaskDTO, 0) + for _, task := range model.Tasks.Tasks() { + tasks = append(tasks, taskDTO(task, 50)) + } + writeJSON(writer, tasks) + case http.MethodPost: + if !s.requireSameOrigin(writer, request) { + return + } + var req taskRequest + if err := json.NewDecoder(request.Body).Decode(&req); err != nil { + http.Error(writer, "Invalid request.", http.StatusBadRequest) + return + } + task, err := startTask(model, req) + if err != nil { + var user *userError + if errors.As(err, &user) { + http.Error(writer, user.Message, http.StatusBadRequest) + return + } + var vfoxErr *vfox.VfoxError + if errors.As(err, &vfoxErr) { + http.Error(writer, vfoxErr.Message, http.StatusBadRequest) + return + } + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + writer.WriteHeader(http.StatusCreated) + writeJSON(writer, taskDTO(task, 50)) + default: + http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) + } +} + +// userError is a rejected task request: invalid input, not a vfox failure. +type userError struct { + Message string +} + +func (e *userError) Error() string { return e.Message } + +// enqueueAddPlugin queues `vfox add` for a URL or a server-side upload +// without going through PreparePluginSource: the web interface never hands +// an HTTP-supplied filesystem path to os.ReadFile. cleanup deletes a +// private upload afterwards, if any. +func enqueueAddPlugin(model *state.AppModel, source, alias string, cleanup func()) (*state.VfoxTask, error) { + command, err := model.Cli.AddPlugin("", source, alias) + if err != nil { + if cleanup != nil { + cleanup() + } + return nil, err + } + if cleanup != nil { + return model.Tasks.EnqueueWithFinalizer(command, cleanup), nil + } + return model.Run(command), nil +} + +// startTask builds the command of req and queues it. +func startTask(model *state.AppModel, req taskRequest) (*state.VfoxTask, error) { + cli := model.Cli + must := func(command vfox.VfoxCommand, err error) (*state.VfoxTask, error) { + if err != nil { + return nil, err + } + return model.Run(command), nil + } + switch req.Operation { + case "install": + if req.Sdk == "" || req.Version == "" { + return nil, &userError{"An SDK and a version are required."} + } + return must(cli.Install(req.Sdk, req.Version)) + case "uninstall": + if req.Sdk == "" || req.Version == "" { + return nil, &userError{"An SDK and a version are required."} + } + return must(cli.Uninstall(req.Sdk, req.Version)) + case "use": + if req.Sdk == "" || req.Version == "" { + return nil, &userError{"An SDK and a version are required."} + } + if req.Scope != "" && req.Scope != "global" { + return nil, &userError{"Only the global scope is offered in the browser; pin projects in the desktop application."} + } + return must(cli.Use(req.Sdk, req.Version, core.UseScopeGlobal, "")) + case "unuse": + if req.Sdk == "" { + return nil, &userError{"An SDK is required."} + } + return must(cli.Unuse(req.Sdk, core.UseScopeGlobal, "")) + case "add": + if req.Source == "" { + return nil, &userError{"A plugin source is required."} + } + if !vfox.IsRemotePluginSource(req.Source) { + return nil, &userError{"Only plugin URLs can be added from the browser; upload local files instead."} + } + return enqueueAddPlugin(model, req.Source, req.Alias, nil) + case "remove": + if req.Sdk == "" { + return nil, &userError{"A plugin is required."} + } + return must(cli.RemovePlugin(req.Sdk)) + case "update": + if req.Sdk == "" { + return model.Run(cli.UpdateAllPlugins()), nil + } + return must(cli.UpdatePlugin(req.Sdk)) + default: + return nil, &userError{"Unknown operation."} + } +} + +// serveTask answers one task and its actions: GET, POST cancel, POST retry +// and DELETE finished. +func (s *Server) serveTask(writer http.ResponseWriter, request *http.Request) { + if !s.requireSession(writer, request) { + return + } + model := s.requireModel(writer) + if model == nil { + return + } + rest := strings.TrimPrefix(request.URL.Path, "/api/tasks/") + idText, action, _ := strings.Cut(rest, "/") + id, err := strconv.Atoi(idText) + if err != nil || strings.Contains(idText, "/") { + if rest == "finished" && request.Method == http.MethodDelete { + if !s.requireSameOrigin(writer, request) { + return + } + model.Tasks.ClearFinished() + writeJSON(writer, map[string]bool{"cleared": true}) + return + } + http.Error(writer, "No such task.", http.StatusNotFound) + return + } + var task *state.VfoxTask + for _, known := range model.Tasks.Tasks() { + if known.ID == id { + task = known + } + } + if task == nil { + http.Error(writer, "No such task.", http.StatusNotFound) + return + } + switch { + case request.Method == http.MethodGet && action == "": + writeJSON(writer, taskDTO(task, 500)) + case request.Method == http.MethodPost && action == "cancel": + if !s.requireSameOrigin(writer, request) { + return + } + model.Tasks.Cancel(task) + writeJSON(writer, taskDTO(task, 50)) + case request.Method == http.MethodPost && action == "retry": + if !s.requireSameOrigin(writer, request) { + return + } + writeJSON(writer, taskDTO(model.Tasks.Retry(task), 50)) + default: + http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) + } +} + +// servePlugins adds a plugin from a URL or an uploaded file. +func (s *Server) servePlugins(writer http.ResponseWriter, request *http.Request) { + if !s.requireSession(writer, request) { + return + } + model := s.requireModel(writer) + if model == nil { + return + } + if request.Method != http.MethodPost { + http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) + return + } + if !s.requireSameOrigin(writer, request) { + return + } + source, alias, cleanup, err := pluginSource(writer, request) + if err != nil { + var user *userError + if errors.As(err, &user) { + http.Error(writer, user.Message, http.StatusBadRequest) + return + } + http.Error(writer, "Cannot read the upload.", http.StatusBadRequest) + return + } + // No deferred cleanup here: on success the task finalizer deletes the + // upload once vfox is done with it, and enqueueAddPlugin cleans up on + // failure. Deleting now would race the queued task. + task, err := enqueueAddPlugin(model, source, alias, cleanup) + if err != nil { + var vfoxErr *vfox.VfoxError + if errors.As(err, &vfoxErr) { + http.Error(writer, vfoxErr.Message, http.StatusBadRequest) + return + } + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + writer.WriteHeader(http.StatusCreated) + writeJSON(writer, taskDTO(task, 50)) +} + +// pluginSource reads a plugin source from JSON or from an uploaded file. It +// returns a cleanup for the uploaded copy, if any. +func pluginSource(writer http.ResponseWriter, request *http.Request) (source, alias string, cleanup func(), err error) { + cleanup = func() {} + contentType := request.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "multipart/form-data") { + // Bound the upload before parsing: ParseMultipartForm alone keeps + // up to 32 MiB in memory and spills the rest to disk without a + // total limit. + request.Body = http.MaxBytesReader(writer, request.Body, 33<<20) + if err := request.ParseMultipartForm(32 << 20); err != nil { //nolint:gosec // Bounded above; G120 does not track MaxBytesReader on the body field. + return "", "", cleanup, err + } + alias = request.FormValue("alias") + file, header, err := request.FormFile("file") + if err != nil { + return "", "", cleanup, &userError{"An uploaded file or a source URL is required."} + } + defer func() { _ = file.Close() }() + name := filepath.Base(header.Filename) + if name == "" || name == "." || name == ".." { + name = "plugin.lua" + } + content, err := io.ReadAll(file) + if err != nil { + return "", "", cleanup, err + } + dir, err := os.MkdirTemp("", "vfox-plus-upload-") + if err != nil { + return "", "", cleanup, err + } + cleanup = func() { _ = os.RemoveAll(dir) } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, content, 0o600); err != nil { + return "", "", cleanup, err + } + return path, alias, cleanup, nil + } + var req taskRequest + if err := json.NewDecoder(request.Body).Decode(&req); err != nil { + return "", "", cleanup, &userError{"A source URL is required."} + } + if req.Source == "" { + return "", "", cleanup, &userError{"A source URL is required."} + } + // Browsers add plugins by URL or by upload. A local path would hand an + // HTTP-supplied filesystem path to os.ReadFile in PreparePluginSource, + // so it is rejected here; the desktop application keeps accepting it. + if !vfox.IsRemotePluginSource(req.Source) { + return "", "", cleanup, &userError{"Only plugin URLs can be added from the browser; upload local files instead."} + } + return req.Source, req.Alias, cleanup, nil +} + +// serveConfig edits one vfox configuration key: PUT sets it, DELETE resets +// it to its default. Either way the configuration is reloaded afterwards. +func (s *Server) serveConfig(writer http.ResponseWriter, request *http.Request) { + if !s.requireSession(writer, request) { + return + } + model := s.requireModel(writer) + if model == nil { + return + } + if !s.requireSameOrigin(writer, request) { + return + } + switch request.Method { + case http.MethodPut: + var req struct { + Key string `json:"key"` + Value string `json:"value"` + } + if err := json.NewDecoder(request.Body).Decode(&req); err != nil || strings.TrimSpace(req.Key) == "" { + http.Error(writer, "A key is required.", http.StatusBadRequest) + return + } + if err := model.VfoxConfig.Set(req.Key, req.Value); err != nil { + writeVfoxError(writer, err) + return + } + writeJSON(writer, map[string]bool{"saved": true}) + case http.MethodDelete: + key := strings.TrimSpace(request.URL.Query().Get("key")) + if key == "" { + http.Error(writer, "A key is required.", http.StatusBadRequest) + return + } + if err := model.VfoxConfig.Reset(key); err != nil { + writeVfoxError(writer, err) + return + } + writeJSON(writer, map[string]bool{"reset": true}) + default: + http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) + } +} + +// serveSettings reads and writes the GUI settings. +func (s *Server) serveSettings(writer http.ResponseWriter, request *http.Request) { + if !s.requireSession(writer, request) { + return + } + model := s.requireModel(writer) + if model == nil { + return + } + switch request.Method { + case http.MethodGet: + writeJSON(writer, settingsDTO(model.Settings.Settings())) + case http.MethodPut: + if !s.requireSameOrigin(writer, request) { + return + } + var req struct { + VfoxExecutable *string `json:"vfoxExecutable"` + ExtraEnvironment map[string]string `json:"extraEnvironment"` + Language *string `json:"language"` + } + if err := json.NewDecoder(request.Body).Decode(&req); err != nil { + http.Error(writer, "Invalid request.", http.StatusBadRequest) + return + } + if req.Language != nil { + switch settings.LanguagePreference(*req.Language) { + case settings.LanguageSystem, settings.LanguageEnglish, settings.LanguageSimplifiedChinese: + default: + http.Error(writer, "Unknown language.", http.StatusBadRequest) + return + } + } + model.Settings.Update(func(current settings.AppSettings) settings.AppSettings { + if req.VfoxExecutable != nil { + current.VfoxExecutable = strings.TrimSpace(*req.VfoxExecutable) + } + if req.ExtraEnvironment != nil { + current.ExtraEnvironment = req.ExtraEnvironment + } + if req.Language != nil { + current.Language = settings.LanguagePreference(*req.Language) + } + return current + }) + writeJSON(writer, settingsDTO(model.Settings.Settings())) + default: + http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) + } +} + +// settingsDTO is the editable subset of the GUI settings. +func settingsDTO(current settings.AppSettings) map[string]interface{} { + return map[string]interface{}{ + "vfoxExecutable": current.VfoxExecutable, + "extraEnvironment": current.ExtraEnvironment, + "language": string(current.Language), + } +} diff --git a/mygo/pair/webapi_test.go b/mygo/pair/webapi_test.go new file mode 100644 index 0000000..81b1c5e --- /dev/null +++ b/mygo/pair/webapi_test.go @@ -0,0 +1,454 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pair_test + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/version-fox/vfox-plus/mygo/core" + "github.com/version-fox/vfox-plus/mygo/core/settings" + "github.com/version-fox/vfox-plus/mygo/core/vfox" + "github.com/version-fox/vfox-plus/mygo/internal/testsupport" + "github.com/version-fox/vfox-plus/mygo/pair" + "github.com/version-fox/vfox-plus/mygo/state" +) + +func testModel(t *testing.T, runner *testsupport.FakeProcessRunner) *state.AppModel { + t.Helper() + model := state.NewAppModel(state.AppModelOptions{ + Runner: runner, + SettingsStore: settings.NewMemorySettingsStore(settings.DefaultSettings()), + Environment: map[string]string{"HOME": "/home/u"}, + Locator: vfox.NewVfoxLocator(map[string]string{}, + func(path string) bool { return path == "/usr/bin/vfox" }), + }) + t.Cleanup(model.Dispose) + model.Start() + deadline := time.Now().Add(5 * time.Second) + for !model.Installed.HasValue() { + if time.Now().After(deadline) { + t.Fatal("timed out waiting for the installed SDKs") + } + time.Sleep(2 * time.Millisecond) + } + return model +} + +func webServer(t *testing.T, model *state.AppModel) *pair.Server { + t.Helper() + server, err := pair.NewServer(pair.Options{ + Snapshot: func() pair.Snapshot { return pair.Snapshot{} }, + Model: model, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close() }) + return server +} + +func webSession(t *testing.T, server *pair.Server) string { + t.Helper() + _, url, err := server.NewToken() + if err != nil { + t.Fatal(err) + } + _, _, cookies := get(t, url, "") + for _, cookie := range cookies { + if cookie.Name == "vfox-plus-session" { + return cookie.Value + } + } + t.Fatal("pairing must issue a session") + return "" +} + +func webGet(t *testing.T, url, session string) (int, string) { + t.Helper() + status, body, _ := get(t, url, session) + return status, body +} + +func webPost(t *testing.T, server *pair.Server, session, path, body, origin string) (int, string) { + t.Helper() + request, err := http.NewRequest(http.MethodPost, server.BaseURL()+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.AddCookie(&http.Cookie{Name: "vfox-plus-session", Value: session, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + request.Header.Set("Content-Type", "application/json") + if origin != "" { + request.Header.Set("Origin", origin) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer func() { _ = response.Body.Close() }() + content, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return response.StatusCode, string(content) +} + +func TestWebAppNeedsPairing(t *testing.T) { + model := testModel(t, testsupport.FixtureRunner()) + server := webServer(t, model) + nofollow := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }} + response, err := nofollow.Get(server.BaseURL() + "/app") + if err != nil { + t.Fatal(err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusFound { + t.Errorf("unpaired browsers must be redirected, got %d", response.StatusCode) + } + session := webSession(t, server) + status, body := webGet(t, server.BaseURL()+"/app", session) + if status != http.StatusOK { + t.Fatalf("got %d", status) + } + for _, want := range []string{"vfox-plus", "/api/events", "taskCount", "sdkList"} { + if !strings.Contains(body, want) { + t.Errorf("the page must contain %q", want) + } + } +} + +func TestWebInventory(t *testing.T) { + model := testModel(t, testsupport.FixtureRunner()) + server := webServer(t, model) + session := webSession(t, server) + status, body := webGet(t, server.BaseURL()+"/api/inventory", session) + if status != http.StatusOK { + t.Fatalf("got %d: %s", status, body) + } + for _, want := range []string{`"availability":"ready"`, "maven", "nodejs", "proxy", `"complete":true`} { + if !strings.Contains(body, want) { + t.Errorf("got %s", body) + } + } + if status, _ := webGet(t, server.BaseURL()+"/api/inventory", ""); status != http.StatusUnauthorized { + t.Errorf("got %d", status) + } +} + +func TestWebSdkDetail(t *testing.T) { + model := testModel(t, testsupport.FixtureRunner()) + server := webServer(t, model) + session := webSession(t, server) + status, body := webGet(t, server.BaseURL()+"/api/sdks/nodejs/info", session) + if status != http.StatusOK || !strings.Contains(body, "nodejs") { + t.Errorf("got %d: %s", status, body) + } + status, body = webGet(t, server.BaseURL()+"/api/sdks/nodejs/versions", session) + if status != http.StatusOK || !strings.Contains(body, "24") { + t.Errorf("got %d: %s", status, body) + } + if status, _ := webGet(t, server.BaseURL()+"/api/sdks/nodejs/nope", session); status != http.StatusNotFound { + t.Errorf("got %d", status) + } +} + +func TestWebTasks(t *testing.T) { + model := testModel(t, testsupport.FixtureRunner()) + server := webServer(t, model) + session := webSession(t, server) + status, body := webPost(t, server, session, "/api/tasks", `{"operation":"install","sdk":"go","version":"1"}`, "") + if status != http.StatusCreated { + t.Fatalf("got %d: %s", status, body) + } + waitTaskStatus(t, server, session, body, `"status":"succeeded"`) + status, body = webGet(t, server.BaseURL()+"/api/tasks", session) + if status != http.StatusOK || !strings.Contains(body, `"operation":"install"`) { + t.Errorf("got %d: %s", status, body) + } + if status, _ := webPost(t, server, session, "/api/tasks", `{"operation":"use","sdk":"go","version":"1","scope":"project"}`, ""); status != http.StatusBadRequest { + t.Errorf("project scopes must be rejected, got %d", status) + } + if status, _ := webPost(t, server, session, "/api/tasks", `{"operation":"add","source":"mine.lua"}`, ""); status != http.StatusBadRequest { + t.Errorf("local plugin paths must be rejected, got %d", status) + } + if status, _ := webPost(t, server, session, "/api/tasks", `{"operation":"bogus"}`, ""); status != http.StatusBadRequest { + t.Errorf("got %d", status) + } + if status, _ := webPost(t, server, session, "/api/tasks", `{"operation":"install","sdk":"go","version":"1"}`, "https://evil.example"); status != http.StatusForbidden { + t.Errorf("cross-site requests must be rejected, got %d", status) + } +} + +func TestWebTaskActions(t *testing.T) { + runner := testsupport.FixtureRunner() + runner.OnStart("update --all", testsupport.NewFakeRunningProcess) + model := testModel(t, runner) + server := webServer(t, model) + session := webSession(t, server) + status, body := webPost(t, server, session, "/api/tasks", `{"operation":"update"}`, "") + if status != http.StatusCreated { + t.Fatalf("got %d: %s", status, body) + } + if !strings.Contains(body, `"status":"running"`) { + t.Fatalf("got %s", body) + } + var created struct { + ID int `json:"id"` + } + decodeJSON(t, body, &created) + do := func(method, path string) (int, string) { + t.Helper() + req, err := http.NewRequest(method, server.BaseURL()+path, nil) + if err != nil { + t.Fatal(err) + } + req.AddCookie(&http.Cookie{Name: "vfox-plus-session", Value: session, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + content, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return resp.StatusCode, string(content) + } + id := "/api/tasks/" + strconv.Itoa(created.ID) + if status, body := do(http.MethodGet, id); status != http.StatusOK || !strings.Contains(body, `"status":"running"`) { + t.Errorf("got %d: %s", status, body) + } + if status, _ := do(http.MethodPost, id+"/cancel"); status != http.StatusOK { + t.Errorf("got %d", status) + } + deadline := time.Now().Add(5 * time.Second) + for { + _, body := do(http.MethodGet, id) + if strings.Contains(body, `"status":"cancelled"`) { + break + } + if time.Now().After(deadline) { + t.Fatalf("the task was not cancelled: %s", body) + } + time.Sleep(2 * time.Millisecond) + } + if status, body := do(http.MethodPost, id+"/retry"); status != http.StatusOK || !strings.Contains(body, `"status":"running"`) { + t.Errorf("got %d: %s", status, body) + } + if status, _ := do(http.MethodDelete, "/api/tasks/finished"); status != http.StatusOK { + t.Errorf("got %d", status) + } + if status, _ := do(http.MethodGet, "/api/tasks/999999"); status != http.StatusNotFound { + t.Errorf("got %d", status) + } +} + +func decodeJSON(t *testing.T, body string, value interface{}) { + t.Helper() + if err := json.Unmarshal([]byte(body), value); err != nil { + t.Fatal(err) + } +} + +func TestWebPlugins(t *testing.T) { + model := testModel(t, testsupport.FixtureRunner()) + server := webServer(t, model) + session := webSession(t, server) + status, body := webPost(t, server, session, "/api/plugins", `{"source":"https://example.com/p.zip"}`, "") + if status != http.StatusCreated || !strings.Contains(body, `"operation":"addPlugin"`) { + t.Errorf("got %d: %s", status, body) + } + for _, local := range []string{`{"source":"/etc/passwd"}`, `{"source":"mine.lua"}`, `{"source":"C:\\mine.lua"}`} { + if status, body := webPost(t, server, session, "/api/plugins", local, ""); status != http.StatusBadRequest { + t.Errorf("local paths must be rejected, got %d: %s", status, body) + } + } + var file bytes.Buffer + writer := multipart.NewWriter(&file) + part, err := writer.CreateFormFile("file", "mine.lua") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte("PLUGIN = {}")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodPost, server.BaseURL()+"/api/plugins", &file) + if err != nil { + t.Fatal(err) + } + request.AddCookie(&http.Cookie{Name: "vfox-plus-session", Value: session, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer func() { _ = response.Body.Close() }() + content, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusCreated { + t.Errorf("got %d: %s", response.StatusCode, content) + } + waitTaskStatus(t, server, session, string(content), `"status":"succeeded"`) +} + +// waitTaskStatus polls the task of body until its JSON contains want. +func waitTaskStatus(t *testing.T, server *pair.Server, session, body, want string) { + t.Helper() + var created struct { + ID int `json:"id"` + } + decodeJSON(t, body, &created) + deadline := time.Now().Add(5 * time.Second) + for { + status, content := webGet(t, server.BaseURL()+"/api/tasks/"+strconv.Itoa(created.ID), session) + if status == http.StatusOK && strings.Contains(content, want) { + return + } + if time.Now().After(deadline) { + t.Fatalf("got %d: %s", status, content) + } + time.Sleep(2 * time.Millisecond) + } +} + +func TestWebSettings(t *testing.T) { + model := testModel(t, testsupport.FixtureRunner()) + server := webServer(t, model) + session := webSession(t, server) + status, body := webGet(t, server.BaseURL()+"/api/settings", session) + if status != http.StatusOK || !strings.Contains(body, `"language"`) { + t.Fatalf("got %d: %s", status, body) + } + put := func(payload string) (int, string) { + t.Helper() + request, err := http.NewRequest(http.MethodPut, server.BaseURL()+"/api/settings", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + request.AddCookie(&http.Cookie{Name: "vfox-plus-session", Value: session, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer func() { _ = response.Body.Close() }() + content, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return response.StatusCode, string(content) + } + if status, body := put(`{"language":"simplifiedChinese"}`); status != http.StatusOK || !strings.Contains(body, "simplifiedChinese") { + t.Errorf("got %d: %s", status, body) + } + if status, _ := put(`{"language":"klingon"}`); status != http.StatusBadRequest { + t.Errorf("got %d", status) + } +} + +func TestWebConfig(t *testing.T) { + runner := testsupport.FixtureRunner(). + On("config proxy.enable true", core.ProcessOutput{}). + On("config --unset proxy.enable", core.ProcessOutput{}) + model := testModel(t, runner) + server := webServer(t, model) + session := webSession(t, server) + put := func(payload string) (int, string) { + t.Helper() + request, err := http.NewRequest(http.MethodPut, server.BaseURL()+"/api/config", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + request.AddCookie(&http.Cookie{Name: "vfox-plus-session", Value: session, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer func() { _ = response.Body.Close() }() + content, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return response.StatusCode, string(content) + } + if status, body := put(`{"key":"proxy.enable","value":"true"}`); status != http.StatusOK || !strings.Contains(body, "saved") { + t.Errorf("got %d: %s", status, body) + } + request, err := http.NewRequest(http.MethodDelete, server.BaseURL()+"/api/config?key=proxy.enable", nil) + if err != nil { + t.Fatal(err) + } + request.AddCookie(&http.Cookie{Name: "vfox-plus-session", Value: session, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + t.Errorf("got %d", response.StatusCode) + } +} + +func TestWebEvents(t *testing.T) { + model := testModel(t, testsupport.FixtureRunner()) + server := webServer(t, model) + session := webSession(t, server) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.BaseURL()+"/api/events", nil) + if err != nil { + t.Fatal(err) + } + request.AddCookie(&http.Cookie{Name: "vfox-plus-session", Value: session, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer func() { _ = response.Body.Close() }() + if contentType := response.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "text/event-stream") { + t.Fatalf("got %q", contentType) + } + reader := bufio.NewReader(response.Body) + deadline := time.Now().Add(10 * time.Second) + for { + if time.Now().After(deadline) { + t.Fatal("timed out waiting for the hello event") + } + line, err := reader.ReadString('\n') + if err != nil { + t.Fatal(err) + } + if strings.HasPrefix(line, "event: hello") { + return + } + } +} diff --git a/mygo/pair/webapp.html b/mygo/pair/webapp.html new file mode 100644 index 0000000..ed1a12d --- /dev/null +++ b/mygo/pair/webapp.html @@ -0,0 +1,349 @@ + + + + + + + +vfox-plus + + + +
+

vfox-plus

+ +… +
+
+
+
+

Installed

+

Pick an SDK.

+
+
+ + + +
+
+ + + diff --git a/mygo/state/appmodel.go b/mygo/state/appmodel.go index 79dc680..ce3b1be 100644 --- a/mygo/state/appmodel.go +++ b/mygo/state/appmodel.go @@ -58,7 +58,6 @@ type AppModel struct { Tasks *TaskQueue mu sync.Mutex - pluginCopies map[int]vfox.PreparedPluginSource stops []Unsubscribe lastAvailability VfoxAvailability appliedSettings *settings.AppSettings @@ -74,7 +73,6 @@ func NewAppModel(options AppModelOptions) *AppModel { config.BaseEnvironment = options.Environment model := &AppModel{ Cli: vfox.NewClient(options.Runner, config), - pluginCopies: make(map[int]vfox.PreparedPluginSource), lastAvailability: VfoxUnknown, } model.Settings = NewSettingsController(options.SettingsStore) @@ -126,6 +124,9 @@ func (m *AppModel) Run(command vfox.VfoxCommand) *VfoxTask { // is copied into the temporary directory of vfox first and vfox gets the // copy; the copy is deleted once the task has finished. It returns an error // if alias is not a valid plugin name. +// +// The copy is bound to the task when it is enqueued, so that a task which +// finishes immediately cannot outrun the registration of the copy. func (m *AppModel) AddPluginFromSource(source, alias string) (*VfoxTask, error) { temporaryRoot := vfox.VfoxTemporaryDirectory(vfox.VfoxEnvironment(m.Cli.LaunchConfig(), false)) prepared := vfox.PreparePluginSource(source, temporaryRoot) @@ -134,13 +135,10 @@ func (m *AppModel) AddPluginFromSource(source, alias string) (*VfoxTask, error) prepared.Dispose() return nil, err } - task := m.Run(command) - if prepared.IsCopy() { - m.mu.Lock() - m.pluginCopies[task.ID] = prepared - m.mu.Unlock() + if !prepared.IsCopy() { + return m.Run(command), nil } - return task, nil + return m.Tasks.EnqueueWithFinalizer(command, prepared.Dispose), nil } func (m *AppModel) onVfoxChanged() { @@ -185,13 +183,6 @@ func (m *AppModel) applySettings() { } func (m *AppModel) onTaskFinished(task *VfoxTask) { - m.mu.Lock() - prepared, ok := m.pluginCopies[task.ID] - delete(m.pluginCopies, task.ID) - m.mu.Unlock() - if ok { - prepared.Dispose() - } if task.Status() == TaskCancelled { if _, started := task.StartedAt(); !started { return @@ -205,13 +196,8 @@ func (m *AppModel) Dispose() { for _, stop := range m.stops { stop() } - m.mu.Lock() - copies := m.pluginCopies - m.pluginCopies = make(map[int]vfox.PreparedPluginSource) - m.mu.Unlock() - for _, prepared := range copies { - prepared.Dispose() - } + // The queue runs the finalizers of the tasks that never finished, which + // deletes the private copies of the plugin files they own. m.Tasks.Dispose() m.Installed.Dispose() m.Registry.Dispose() diff --git a/mygo/state/taskqueue.go b/mygo/state/taskqueue.go index 00c3559..c9fec86 100644 --- a/mygo/state/taskqueue.go +++ b/mygo/state/taskqueue.go @@ -72,9 +72,8 @@ type VfoxTask struct { // Command is the command that is executed. Command vfox.VfoxCommand // CreatedAt is when the task was queued. - CreatedAt time.Time - maxLogLines int - + CreatedAt time.Time + maxLogLines int mu sync.Mutex lines []string stdoutLines []string @@ -92,6 +91,12 @@ type VfoxTask struct { hasExitCode bool errorMessage string hasError bool + // finalizer releases a resource the task owns, such as the private copy + // of a local plugin file. It runs exactly once, when the task is + // announced as finished or when the queue is disposed. It is set when + // the task is enqueued, so that a task which finishes immediately + // cannot outrun the registration of the resource. + finalizer func() } func newTask(id int, command vfox.VfoxCommand, maxLogLines int) *VfoxTask { @@ -290,6 +295,18 @@ func (t *VfoxTask) fail(message string) { } } +// runFinalizer releases the resource the task owns, if any. It runs at most +// once, even when the queue is disposed while the task is running. +func (t *VfoxTask) runFinalizer() { + t.mu.Lock() + finalizer := t.finalizer + t.finalizer = nil + t.mu.Unlock() + if finalizer != nil { + finalizer() + } +} + // taskOutput feeds the output of a process into a task, line by line. type taskOutput struct { task *VfoxTask @@ -407,14 +424,33 @@ func (q *TaskQueue) OnFinished(listener func(*VfoxTask)) Unsubscribe { // If an identical command is already queued or running, its task is returned // instead of queueing the command twice. func (q *TaskQueue) Enqueue(command vfox.VfoxCommand) *VfoxTask { + return q.EnqueueWithFinalizer(command, nil) +} + +// EnqueueWithFinalizer queues command like Enqueue, and runs finalizer +// exactly once when the task is announced as finished or when the queue is +// disposed, whichever comes first. +// +// The finalizer is bound to the task before it can start, so that a task +// which finishes immediately cannot outrun the registration of the resource +// it owns. When an identical command is already queued or running, the +// existing task is returned and the finalizer runs immediately, because no +// new task takes ownership of the resource. +func (q *TaskQueue) EnqueueWithFinalizer(command vfox.VfoxCommand, finalizer func()) *VfoxTask { q.mu.Lock() for element := q.all.Front(); element != nil; element = element.Next() { if task := element.Value.(*VfoxTask); task.Status().IsActive() && sameCommand(task.Command, command) { q.mu.Unlock() + if finalizer != nil { + finalizer() + } return task } } task := newTask(q.nextID, command, q.MaxLogLines) + if finalizer != nil { + task.finalizer = finalizer + } q.nextID++ q.all.PushBack(task) q.trimFinishedLocked() @@ -544,6 +580,7 @@ func (q *TaskQueue) run(task *VfoxTask) { // announce tells the listeners that task has finished. func (q *TaskQueue) announce(task *VfoxTask) { + task.runFinalizer() task.NotifyListeners() if q.IsDisposed() { return @@ -570,11 +607,18 @@ func (q *TaskQueue) Dispose() { // by run, and one that runs is killed here. q.Notifier.Dispose() q.finished.Close() - for _, task := range q.Tasks() { + tasks := q.Tasks() + for _, task := range tasks { if process := task.runningProcess(); process != nil { process.Kill() } } + // A queued task never reaches execute, so its finalizer would never run. + // runFinalizer runs at most once per task, even for a task that is + // finishing concurrently. + for _, task := range tasks { + task.runFinalizer() + } } func sameCommand(a, b vfox.VfoxCommand) bool { diff --git a/mygo/state/taskqueue_test.go b/mygo/state/taskqueue_test.go index 8bf169c..a249120 100644 --- a/mygo/state/taskqueue_test.go +++ b/mygo/state/taskqueue_test.go @@ -387,3 +387,76 @@ func TestDisposeKillsStartedProcesses(t *testing.T) { t.Errorf("got %d invocations", len(runner.Invocations())) } } + +func TestFinalizerRunsWhenTheTaskFinishes(t *testing.T) { + runner := testsupport.NewFakeProcessRunner() + // An unmatched streamed command completes immediately. + cli := testClient(runner) + queue := state.NewTaskQueue(cli, 3, 2) + defer queue.Dispose() + finished := collectFinished(queue) + var mu sync.Mutex + calls := 0 + task := queue.EnqueueWithFinalizer(mustCommand(cli.Install("go", "1")), func() { + mu.Lock() + defer mu.Unlock() + calls++ + }) + waitFor(t, "the task to finish", func() bool { return finished.count() == 1 }) + if task.Status() != state.TaskSucceeded { + t.Fatalf("got %v", task.Status()) + } + mu.Lock() + defer mu.Unlock() + if calls != 1 { + t.Errorf("the finalizer ran %d times, want once", calls) + } +} + +func TestFinalizerRunsImmediatelyWhenTheCommandIsDeduplicated(t *testing.T) { + runner := testsupport.NewFakeProcessRunner() + cli := testClient(runner) + runner.OnStart("update --all", testsupport.NewFakeRunningProcess) + queue := state.NewTaskQueue(cli, 3, 2) + defer queue.Dispose() + first := queue.Enqueue(cli.UpdateAllPlugins()) + var mu sync.Mutex + calls := 0 + second := queue.EnqueueWithFinalizer(cli.UpdateAllPlugins(), func() { + mu.Lock() + defer mu.Unlock() + calls++ + }) + if second != first { + t.Error("an identical active command must share its task") + } + mu.Lock() + defer mu.Unlock() + if calls != 1 { + t.Errorf("the orphaned finalizer ran %d times, want once", calls) + } +} + +func TestFinalizerRunsForQueuedTasksOnDispose(t *testing.T) { + runner := testsupport.NewFakeProcessRunner() + cli := testClient(runner) + runner.OnStart("update --all", testsupport.NewFakeRunningProcess) + queue := state.NewTaskQueue(cli, 3, 2) + queue.Enqueue(cli.UpdateAllPlugins()) + var mu sync.Mutex + calls := 0 + queued := queue.EnqueueWithFinalizer(mustCommand(cli.Install("go", "1")), func() { + mu.Lock() + defer mu.Unlock() + calls++ + }) + if queued.Status() != state.TaskQueued { + t.Fatalf("got %v", queued.Status()) + } + queue.Dispose() + mu.Lock() + defer mu.Unlock() + if calls != 1 { + t.Errorf("the queued finalizer ran %d times, want once", calls) + } +} diff --git a/shared/packaging/windows/README.md b/shared/packaging/windows/README.md index 36feffd..c997dca 100644 --- a/shared/packaging/windows/README.md +++ b/shared/packaging/windows/README.md @@ -10,7 +10,7 @@ packages (one per architecture) and a `.msixbundle` for **Windows 11**. | --- | --- | | `README.md` | This document (English). | | `README.zh-CN.md` | Chinese translation. | -| [`Sign-Msix.ps1`](Sign-Msix.ps1) | One-command self-sign and install on Windows 11: creates a self-signed certificate, rebuilds the packages with the matching publisher, signs them with osslsigncode and installs the bundle. | +| [`Sign-Msix.ps1`](Sign-Msix.ps1) | Helper script for maintainers to rebuild and sign packages locally; users should not need it (see below). | The package identity, the manifest, the logos and the bundle layout are generated by `vfxtool build-msix` @@ -51,46 +51,38 @@ from `SOURCE_DATE_EPOCH`. ## Installing on Windows 11 -No third-party certificate is needed. Extract the `vfox-plus-windows` -artifact (it holds the unsigned packages, the loose executables, a Windows -`vfxtool.exe` and [`Sign-Msix.ps1`](Sign-Msix.ps1)), then run the script in -PowerShell. It relaunches itself elevated and does everything: create (or reuse) -a self-signed code-signing certificate, rebuild the `.msix`/`.msixbundle` with -the matching publisher, sign them with osslsigncode (downloaded automatically, -no Windows SDK needed), trust the certificate for the local machine and install -the bundle. +Extract the `vfox-plus-windows` artifact (it holds the unsigned packages next +to the staged executables and a Windows `vfxtool.exe`), then install the +bundle in an elevated PowerShell: ```powershell -powershell -ExecutionPolicy Bypass -File Sign-Msix.ps1 +Add-AppxPackage -Path .\vfox-plus_0.1.0.msixbundle -AllowUnsigned ``` -Re-running is safe: the existing certificate is reused and the install is -repeated. `Sign-Msix.ps1 -SkipInstall` rebuilds and signs without installing; -`-PublisherCN ` uses another publisher name; `-Force` creates a fresh -certificate. The script keeps the private key in your own certificate store — -only you can sign with it. - -### Manual install (unsigned) - -Without signing, the unsigned packages install with `Add-AppxPackage --AllowUnsigned` in an elevated PowerShell (executable content installs for all -users): +The deployment platform picks the slice matching the machine, and the package +installs for all users. To remove it again: ```powershell -Add-AppxPackage -Path .\vfox-plus_0.1.0.msixbundle -AllowUnsigned +Remove-AppxPackage -Package dev.vfox.VfoxPlus__neutral_~_ ``` +(Get the full package name with `Get-AppxPackage -Name dev.vfox.VfoxPlus`.) + To run from source without packaging, build on Windows and run the exe directly. +## Signing status + +The `.msix` and `.msixbundle` files are currently **unsigned**. They install +with `Add-AppxPackage -AllowUnsigned` as above, and no self-signing is needed. +An officially signed bundle will be provided later. + ## Signing notes (for maintainers) - A signed package's publisher must exactly equal the certificate subject, with - no OID — which is why the script rebuilds the packages instead of signing the + no OID — which is why signing rebuilds the packages instead of signing the unsigned ones in place (their publisher carries the unsigned-package OID). -- A self-signed certificate only convinces machines that trust it, which is - exactly what the script sets up locally. For public distribution without any - per-machine step, either publish to the Microsoft Store (Microsoft signs) or - sign with a publicly trusted code-signing certificate or Azure Trusted - Signing; the build is the same, only `--publisher` and the signing step - change. +- For public distribution without any per-machine step, either publish to the + Microsoft Store (Microsoft signs) or sign with a publicly trusted + code-signing certificate or Azure Trusted Signing; the build is the same, + only `--publisher` and the signing step change. diff --git a/shared/packaging/windows/README.zh-CN.md b/shared/packaging/windows/README.zh-CN.md index 2f31c22..064a804 100644 --- a/shared/packaging/windows/README.zh-CN.md +++ b/shared/packaging/windows/README.zh-CN.md @@ -10,12 +10,7 @@ | --- | --- | | `README.md` | 本文档(英文)。 | | `README.zh-CN.md` | 中文译文。 | -| [`Sign-Msix.ps1`](Sign-Msix.ps1) | Windows 11 一键自签名安装:创建自签名代码签名证书、用匹配的发布者重建包、用 osslsigncode 签名并安装 bundle。 | - -| 文件 | 用途 | -| --- | --- | -| `README.md` | 本文档(英文)。 | -| `README.zh-CN.md` | 中文译文。 | +| [`Sign-Msix.ps1`](Sign-Msix.ps1) | 维护者本地重建与签名包的辅助脚本;用户不需要使用(见下)。 | 包标识、清单、徽标与 bundle 布局由 `vfxtool build-msix` (`shared/tooling/internal/buildmsix`)生成;此处不提交任何二进制。 @@ -53,35 +48,32 @@ SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" vfxtool build-msix \ ## 在 Windows 11 安装 -不需要第三方证书。解压 `vfox-plus-windows` 构件(内含未签名包、松散可执行文件、 -Windows 版 `vfxtool.exe` 与 [`Sign-Msix.ps1`](Sign-Msix.ps1)),在 PowerShell 跑脚本。 -脚本自动提权,一次做完所有事:创建(或复用)自签名代码签名证书、用匹配的发布者 -重建 `.msix`/`.msixbundle`、用 osslsigncode 签名(自动下载,无需 Windows SDK)、 -信任证书并安装 bundle。 +解压 `vfox-plus-windows` 构件(内含未签名包、暂存可执行文件与 Windows 版 +`vfxtool.exe`),在管理员 PowerShell 安装 bundle: ```powershell -powershell -ExecutionPolicy Bypass -File Sign-Msix.ps1 +Add-AppxPackage -Path .\vfox-plus_0.1.0.msixbundle -AllowUnsigned ``` -重复跑安全:复用已有证书并重新安装。`Sign-Msix.ps1 -SkipInstall` 只重建签名不安装; -`-PublisherCN <名称>` 换发布者名;`-Force` 生成新证书。私钥只存在你自己的证书存储中, -只有你能签名。 - -### 手动安装(未签名) - -不签名时,未签名包用管理员 PowerShell `Add-AppxPackage -AllowUnsigned` 安装 -(可执行内容为所有用户安装): +部署平台自动选择与本机匹配的架构切片,包为所有用户安装。卸载: ```powershell -Add-AppxPackage -Path .\vfox-plus_0.1.0.msixbundle -AllowUnsigned +Remove-AppxPackage -Package dev.vfox.VfoxPlus_<版本>_neutral_~_<发布者标识> ``` +(用 `Get-AppxPackage -Name dev.vfox.VfoxPlus` 查看完整包名。) + 不打包直接从源码跑,在 Windows 上构建后直接运行 exe 即可。 +## 签名状态 + +`.msix` 与 `.msixbundle` 目前**未签名**,按上用 `Add-AppxPackage -AllowUnsigned` +安装即可,无需自签名。日后会提供官方签名的安装包。 + ## 签名说明(维护者) -- 已签名包的发布者必须与证书主题完全一致、不带 OID——脚本因此重建包,而非直接签 +- 已签名包的发布者必须与证书主题完全一致、不带 OID——签名因此重建包,而非直接签 未签名包(其发布者带未签名 OID)。 -- 自签名证书只在信任它的机器上有效,脚本恰好在本地建立该信任。公开发布且不希望每台 - 机器单独信任,可上架 Microsoft Store(由微软签名),或用公开受信的代码签名证书 / - Azure Trusted Signing 签名;构建相同,只换 `--publisher` 与签名步骤。 +- 公开发布且不希望每台机器单独信任,可上架 Microsoft Store(由微软签名),或用 + 公开受信的代码签名证书 / Azure Trusted Signing 签名;构建相同,只换 `--publisher` + 与签名步骤。