feat(m4): verify signed surface bundles across hosts
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# LineUp Wails v3 对照 Spike(M4-05)
|
||||
|
||||
此目录是受限的 Wails 可行性验证,不是第二套 LineUp 客户端、更不是生产发布物。
|
||||
|
||||
固定依赖为 `github.com/wailsapp/wails/v3 v3.0.0-beta.3`。`go test ./contract` 在本机执行 Tauri Reference Host 维护的同一份版本化协议 fixture:`../tauri/src/runtime/golden/lineup-v1.json`。`GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c ./...` 则编译真实 Wails `application` API;这是为不具备 GTK4/WebKitGTK 的 Linux 开发机保留的 API/跨编译门禁。
|
||||
|
||||
当前结论:Wails 能作为桌面 Host 候选运行同一份纯数据协议、Tool lifecycle 与安全拒绝 fixture;它不能因此获得任何比 Tauri 更多的权限。Surface 必须继续由 Host 校验 artifact、以 opaque/隔离 WebView 承载,并且不能注入 Wails bindings;Capability 只能由顶层 Host 在显式同意后执行。
|
||||
|
||||
本 Spike 的边界和后续准入:
|
||||
|
||||
- 已验证:beta.3 Go module、Wails application API 的 Windows/amd64 交叉编译、共享 golden fixture 的本机可执行投影、Surface source 字段拒绝。
|
||||
- 尚不作为生产批准:完整窗口隔离/CSP、签名 artifact 加载、原生 capability handler、移动端和发布包,需要在 Wails 版本稳定后用与 Tauri 相同的有头验收清单完成。
|
||||
- 这不改变 Android 当前暂缓状态,也不改变 LineUp v1 协议。
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package contract is deliberately framework-free: production Wails wiring
|
||||
// must not change LineUp's envelope/state semantics.
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
Contract string `json:"contract"`
|
||||
Cases []testCase `json:"cases"`
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
ID string `json:"id"`
|
||||
Expected expected `json:"expected"`
|
||||
Envelopes []envelope `json:"envelopes"`
|
||||
Transitions []transition `json:"transitions"`
|
||||
}
|
||||
|
||||
type expected struct {
|
||||
ItemKind string `json:"item_kind"`
|
||||
Fallback string `json:"fallback"`
|
||||
KernelToolStatus string `json:"kernel_tool_status"`
|
||||
}
|
||||
|
||||
type transition struct {
|
||||
AfterEnvelope int `json:"after_envelope"`
|
||||
Kind string `json:"kind"`
|
||||
CallID string `json:"call_id"`
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Version int `json:"v"`
|
||||
Type string `json:"type"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
func TestSharedLineUpV1GoldenContract(t *testing.T) {
|
||||
bytes, err := os.ReadFile(filepath.Join("..", "..", "tauri", "src", "runtime", "golden", "lineup-v1.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read shared fixture: %v", err)
|
||||
}
|
||||
var spec fixture
|
||||
if err := json.Unmarshal(bytes, &spec); err != nil {
|
||||
t.Fatalf("parse shared fixture: %v", err)
|
||||
}
|
||||
if spec.Contract != "lineup-v1-golden-1" || len(spec.Cases) != 6 {
|
||||
t.Fatalf("unexpected fixture declaration: %q (%d cases)", spec.Contract, len(spec.Cases))
|
||||
}
|
||||
for _, c := range spec.Cases {
|
||||
t.Run(c.ID, func(t *testing.T) {
|
||||
toolStates := map[string]string{}
|
||||
kind, fallback := "", ""
|
||||
for index, wire := range c.Envelopes {
|
||||
kind, fallback = project(wire)
|
||||
if kind == "tool-call" {
|
||||
toolStates[stringValue(wire.Payload["call_id"])] = "pending"
|
||||
}
|
||||
for _, action := range c.Transitions {
|
||||
if action.AfterEnvelope == index && action.Kind == "submit_tool_call" && toolStates[action.CallID] == "pending" {
|
||||
toolStates[action.CallID] = "submitted"
|
||||
}
|
||||
}
|
||||
if kind == "tool-result" {
|
||||
callID := stringValue(wire.Payload["call_id"])
|
||||
if toolStates[callID] == "submitted" {
|
||||
toolStates[callID] = stringValue(wire.Payload["status"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind != c.Expected.ItemKind || fallback != c.Expected.Fallback {
|
||||
t.Fatalf("got kind=%q fallback=%q; want kind=%q fallback=%q", kind, fallback, c.Expected.ItemKind, c.Expected.Fallback)
|
||||
}
|
||||
if c.Expected.KernelToolStatus != "" {
|
||||
ok := false
|
||||
for _, state := range toolStates {
|
||||
ok = ok || state == c.Expected.KernelToolStatus
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("tool state never reached %q: %#v", c.Expected.KernelToolStatus, toolStates)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func project(wire envelope) (kind, fallback string) {
|
||||
if wire.Version != 1 {
|
||||
return "fallback", "unsupported_version"
|
||||
}
|
||||
switch wire.Type {
|
||||
case "lineup.v1.tool.call":
|
||||
if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["tool"]) == "" {
|
||||
return "fallback", "invalid_payload"
|
||||
}
|
||||
return "tool-call", ""
|
||||
case "lineup.v1.tool.result":
|
||||
if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["status"]) == "" {
|
||||
return "fallback", "invalid_payload"
|
||||
}
|
||||
return "tool-result", ""
|
||||
case "lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close":
|
||||
for _, forbidden := range []string{"bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"} {
|
||||
if _, exists := wire.Payload[forbidden]; exists {
|
||||
return "fallback", "invalid_payload"
|
||||
}
|
||||
}
|
||||
return "surface", ""
|
||||
case "lineup.v1.app.call":
|
||||
if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["capability"]) == "" || stringValue(wire.Payload["reason"]) == "" || stringValue(wire.Payload["expires_at"]) == "" {
|
||||
return "fallback", "invalid_payload"
|
||||
}
|
||||
if _, ok := wire.Payload["arguments"].(map[string]interface{}); !ok {
|
||||
return "fallback", "invalid_payload"
|
||||
}
|
||||
return "app-call", ""
|
||||
default:
|
||||
return "fallback", "unsupported_type"
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(value interface{}) string {
|
||||
text, _ := value.(string)
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
module lineup-wails-spike
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/wailsapp/wails/v3 v3.0.0-beta.3
|
||||
|
||||
require (
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.3 h1:BrcZunEBVucncRx+xgkk9TzlXU4qc0ygJuEhKAAGaeA=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.3/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
@@ -0,0 +1,30 @@
|
||||
// The Wails M4 comparison host intentionally has no LineUp business logic in
|
||||
// Go. The rendered shell consumes the same host-neutral contract fixture as
|
||||
// Tauri; Go owns only native lifecycle and capability boundaries.
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
func newSpikeApplication() *application.App {
|
||||
app := application.New(application.Options{
|
||||
Name: "LineUp Wails Contract Spike",
|
||||
Description: "M4 parity probe; not a production LineUp Host",
|
||||
Assets: application.AssetOptions{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte("<!doctype html><meta charset=\"utf-8\"><title>LineUp Wails Contract Spike</title><main>Wails contract spike</main>"))
|
||||
})},
|
||||
})
|
||||
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: "LineUp Wails Contract Spike",
|
||||
URL: "/",
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = newSpikeApplication().Run()
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
type goldenFixture struct {
|
||||
Contract string `json:"contract"`
|
||||
Cases []goldenCase `json:"cases"`
|
||||
}
|
||||
|
||||
type goldenCase struct {
|
||||
ID string `json:"id"`
|
||||
Expected goldenExpected `json:"expected"`
|
||||
Envelopes []wireEnvelope `json:"envelopes"`
|
||||
Transitions []hostTransition `json:"transitions"`
|
||||
}
|
||||
|
||||
type goldenExpected struct {
|
||||
ItemKind string `json:"item_kind"`
|
||||
Fallback string `json:"fallback"`
|
||||
KernelToolStatus string `json:"kernel_tool_status"`
|
||||
}
|
||||
|
||||
type hostTransition struct {
|
||||
AfterEnvelope int `json:"after_envelope"`
|
||||
Kind string `json:"kind"`
|
||||
CallID string `json:"call_id"`
|
||||
}
|
||||
|
||||
type wireEnvelope struct {
|
||||
Version int `json:"v"`
|
||||
Type string `json:"type"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// TestGoldenContract is deliberately independent of Wails' JS bindings. It
|
||||
// proves that a Wails Host can run the exact versioned fixture, and that the
|
||||
// host's admission gate remains data-only before a WebView receives anything.
|
||||
func TestGoldenContract(t *testing.T) {
|
||||
var fixture goldenFixture
|
||||
path := filepath.Join("..", "tauri", "src", "runtime", "golden", "lineup-v1.json")
|
||||
bytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read shared golden fixture: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(bytes, &fixture); err != nil {
|
||||
t.Fatalf("parse shared golden fixture: %v", err)
|
||||
}
|
||||
if fixture.Contract != "lineup-v1-golden-1" {
|
||||
t.Fatalf("unexpected golden fixture contract %q", fixture.Contract)
|
||||
}
|
||||
if len(fixture.Cases) != 6 {
|
||||
t.Fatalf("expected six shared cases, got %d", len(fixture.Cases))
|
||||
}
|
||||
|
||||
for _, test := range fixture.Cases {
|
||||
t.Run(test.ID, func(t *testing.T) {
|
||||
status := map[string]string{}
|
||||
kind := ""
|
||||
fallback := ""
|
||||
for index, envelope := range test.Envelopes {
|
||||
kind, fallback = project(envelope)
|
||||
if kind == "tool-call" {
|
||||
status[stringValue(envelope.Payload["call_id"])] = "pending"
|
||||
}
|
||||
if kind == "tool-result" {
|
||||
callID := stringValue(envelope.Payload["call_id"])
|
||||
if status[callID] == "submitted" {
|
||||
status[callID] = stringValue(envelope.Payload["status"])
|
||||
}
|
||||
}
|
||||
for _, transition := range test.Transitions {
|
||||
if transition.AfterEnvelope == index && transition.Kind == "submit_tool_call" && status[transition.CallID] == "pending" {
|
||||
status[transition.CallID] = "submitted"
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind != test.Expected.ItemKind {
|
||||
t.Fatalf("projection = %q, want %q (fallback %q)", kind, test.Expected.ItemKind, fallback)
|
||||
}
|
||||
if fallback != test.Expected.Fallback {
|
||||
t.Fatalf("fallback = %q, want %q", fallback, test.Expected.Fallback)
|
||||
}
|
||||
if test.Expected.KernelToolStatus != "" {
|
||||
matched := false
|
||||
for _, got := range status {
|
||||
matched = matched || got == test.Expected.KernelToolStatus
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("no tool lifecycle reached %q: %#v", test.Expected.KernelToolStatus, status)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time integration boundary: the Spike uses the actual Wails v3 API,
|
||||
// but LineUp never grants a Surface a Wails binding. Any future production
|
||||
// implementation must retain this separation.
|
||||
func TestWailsApplicationBoundaryCompiles(t *testing.T) {
|
||||
var options application.Options
|
||||
if options.Name != "" {
|
||||
t.Fatal("zero-value options changed unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func project(envelope wireEnvelope) (kind, fallback string) {
|
||||
if envelope.Version != 1 {
|
||||
return "fallback", "unsupported_version"
|
||||
}
|
||||
switch envelope.Type {
|
||||
case "lineup.v1.tool.call":
|
||||
if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["tool"]) == "" {
|
||||
return "fallback", "invalid_payload"
|
||||
}
|
||||
return "tool-call", ""
|
||||
case "lineup.v1.tool.result":
|
||||
if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["status"]) == "" {
|
||||
return "fallback", "invalid_payload"
|
||||
}
|
||||
return "tool-result", ""
|
||||
case "lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close":
|
||||
for _, forbidden := range []string{"bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"} {
|
||||
if _, exists := envelope.Payload[forbidden]; exists {
|
||||
return "fallback", "invalid_payload"
|
||||
}
|
||||
}
|
||||
return "surface", ""
|
||||
case "lineup.v1.app.call":
|
||||
if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["capability"]) == "" || stringValue(envelope.Payload["reason"]) == "" || stringValue(envelope.Payload["expires_at"]) == "" {
|
||||
return "fallback", "invalid_payload"
|
||||
}
|
||||
if _, ok := envelope.Payload["arguments"].(map[string]interface{}); !ok {
|
||||
return "fallback", "invalid_payload"
|
||||
}
|
||||
return "app-call", ""
|
||||
default:
|
||||
return "fallback", "unsupported_type"
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(value interface{}) string {
|
||||
text, _ := value.(string)
|
||||
return text
|
||||
}
|
||||
Reference in New Issue
Block a user