feat(studio): retire the Wails shell
Section 6 of the migration contract bound these into one transaction, because deleting any one first leaves a broken shell rather than a smaller tree: desktop/next and its event pump, remote_pump.go, /rx-replay, the frontend's WAILS_* constants and bus branch, the apiPaths/apiPrefixes whitelist, the vendored go-webview2 fork, and the Wails dependency with its CGO and GTK/WebKitGTK build requirements. They go together here. What allows it now is the condition section 7 named — no exclusive semantics left — plus the one ledger entry that had been recorded as unverified: the release line. studio-v2.11.0 (2026-09-01) through studio-v2.16.0 shipped the Electron bundle seven times, and the crossing from an installed Wails 2.10 was confirmed by the maintainer rather than assumed here. The kernel loses what only that shell reached: update.LocalApplication (a process that is its own application, which Electron's is not), update.Here, appidentity's AppUserModelID application and shortcut repair — Electron states its own appId — and crashreport.CaptureStudioPanic. AppUserModelID itself stays, because Windows toasts still carry it. Two consequences worth stating. The desktop module is now CGO-free, so neither CI nor a contributor installs GTK or WebKitGTK to build it, and the webkit2_41 tag is gone from every command. And what the old shell installed is untouched: winuninstall, the Electron shell's legacy takeover and studio_line's binary name are how an installed 2.10 becomes a 2.11, and none of them are Wails code. The contract becomes docs/STUDIO_SHELL_BOUNDARIES.md: the rules the migration established outlive it, and the ledgers it kept do not. The presentation gaps it tracked are carried over as known gaps, with the host's missing panic capture added to them.
No files matched your search
@@ -337,15 +337,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# WebKitGTK 4.1 is what studio-build.sh links against: 4.0
|
||||
# (libwebkit2gtk-4.0.so.37) is gone on Ubuntu 24.04+ and Fedora 40+.
|
||||
- name: Install Linux build deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- name: vet
|
||||
run: go vet -tags webkit2_41 ./...
|
||||
run: go vet ./...
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
@@ -355,16 +348,15 @@ jobs:
|
||||
args: --timeout=5m
|
||||
|
||||
- name: build
|
||||
run: go build -tags webkit2_41 ./...
|
||||
run: go build ./...
|
||||
|
||||
- name: test
|
||||
run: go test -tags webkit2_41 ./...
|
||||
run: go test ./...
|
||||
|
||||
# The shell's tab/rebuild fences and extension UI are shared state.
|
||||
# Race-sweep the module so regressions are CI-blocked, not just
|
||||
# author-verified locally.
|
||||
# The update helper replaces a running application under pkexec, so its
|
||||
# own concurrency is CI-blocked rather than author-verified locally.
|
||||
- name: test -race
|
||||
run: go test -race -tags webkit2_41 ./...
|
||||
run: go test -race ./...
|
||||
|
||||
# desktop/ is a separate module, so the root macOS matrix above does not
|
||||
# compile it against a real macOS toolchain.
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
name: Studio
|
||||
|
||||
# Studio's own surface — desktop/next (the Wails shell) and desktop/frontend-next
|
||||
# (the SPA it serves) — exists only on the studio branch, so it cannot live in
|
||||
# ci.yml without failing on main-v2 where neither directory is present. ci.yml
|
||||
# covers the kernel and the desktop module's Go side; this file covers the
|
||||
# frontend build and proves the shell still compiles on every platform it ships
|
||||
# to, which is the same command release-studio.yml runs.
|
||||
# Studio's own surface — desktop/frontend-next (the SPA) and the Electron shell
|
||||
# that serves it — exists only on the studio branch, so it cannot live in ci.yml
|
||||
# without failing on main-v2 where neither directory is present. ci.yml covers
|
||||
# the kernel; this file covers the frontend build, the desktop module's Go side,
|
||||
# and the packaging contract on real artifacts.
|
||||
on:
|
||||
push:
|
||||
branches: [studio]
|
||||
@@ -82,8 +81,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shell:
|
||||
name: shell (${{ matrix.os }})
|
||||
desktop:
|
||||
name: desktop (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -101,21 +100,11 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
# WebKitGTK 4.1, matching what release-studio.yml links against: 4.0
|
||||
# (libwebkit2gtk-4.0.so.37) is gone on Ubuntu 24.04+ and Fedora 40+.
|
||||
- name: Install Linux build deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- name: build shell
|
||||
run: bash ../scripts/studio-build.sh --shell-only
|
||||
|
||||
# The desktop module's Go tests, which no job ran until now: the elevated
|
||||
# update helper, the release-side tools and the Wails shell were green
|
||||
# only where a maintainer happened to run them. The code this gate covers
|
||||
# is what replaces a running application on a user's disk.
|
||||
# The desktop module's Go tests: the elevated update helper, the
|
||||
# release-side tools and the Windows uninstall planner. The code this
|
||||
# gate covers is what replaces a running application on a user's disk.
|
||||
# The module is CGO-free since the Wails shell retired, so no platform
|
||||
# carries build dependencies of its own.
|
||||
# Same command as `make studio-test`; this job is already in desktop/.
|
||||
- name: go test
|
||||
run: go test ./...
|
||||
|
||||
@@ -1,70 +1,60 @@
|
||||
# Reasonix Studio (Wails shell)
|
||||
# Reasonix Studio
|
||||
|
||||
A native desktop window around the Reasonix Go kernel. The window hosts a React
|
||||
SPA; every request the SPA makes is routed in-process to the kernel's own HTTP
|
||||
server (`internal/serve`) instead of to a set of bound Go methods, so the
|
||||
desktop, `reasonix serve`, and the remote UI all answer the same routes.
|
||||
A desktop window around the Reasonix Go kernel. The window hosts a React SPA,
|
||||
and every request the SPA makes reaches the kernel's own HTTP server
|
||||
(`internal/serve`) over loopback, so Studio, `reasonix serve` and the remote UI
|
||||
all answer the same routes.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ webview (React + TS, Vite) — desktop/frontend-next │
|
||||
│ fetch("/api/…") · EventSource("/api/events") │
|
||||
│ renderer (React + TS, Vite) — desktop/frontend-next │
|
||||
│ fetch("/…") · EventSource("/events") │
|
||||
└───────────────▲────────────────────────────┬─────────────────┘
|
||||
│ │
|
||||
│ HTTP + SSE over 127.0.0.1 │
|
||||
┌───────────────┴────────────────────────────▼─────────────────┐
|
||||
│ desktop/next main.go │
|
||||
│ isAPIPath / isHubPath ──▶ serve.Server (kernel HTTP mux) │
|
||||
│ everything else ──▶ frontendAssets() (the SPA) │
|
||||
│ desktop/electron main process │
|
||||
│ windows, menus, dialogs, tray, the platform opener │
|
||||
│ owns OS capability and no business state │
|
||||
└───────────────▲────────────────────────────┬─────────────────┘
|
||||
│ │
|
||||
│ spawns, holds the lease │
|
||||
┌───────────────┴────────────────────────────▼─────────────────┐
|
||||
│ internal/boot.Build → internal/control.Controller (kernel) │
|
||||
│ (same assembly the CLI uses: providers, tools, gate, …) │
|
||||
│ cmd/reasonix-studio-host (main module, CGO-free) │
|
||||
│ internal/boot.Build → internal/control.Controller │
|
||||
│ (same assembly the CLI uses: providers, tools, gate, …) │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Because the SPA speaks HTTP rather than `window.go.*`, `desktop/next` stays
|
||||
thin: it owns the window, the platform glue (dock icon, file drop, window fit),
|
||||
and updates. Anything a frontend needs is a kernel route, and
|
||||
`next/route_parity_test.go` fails if the shell routes one of them to the assets.
|
||||
The renderer never learns which shell it is in: anything a page cannot do it
|
||||
asks `HostPort` (`frontend-next/src/port/host.ts`) for, and the main process
|
||||
answers over IPC that carries OS capability only. The boundaries this runs on
|
||||
are written down in [`docs/STUDIO_SHELL_BOUNDARIES.md`](../docs/STUDIO_SHELL_BOUNDARIES.md).
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What it is |
|
||||
| --- | --- |
|
||||
| `next/` | the Wails shell: window, routing, updates, platform glue |
|
||||
| `electron/` | the shell: main process, preload bridge, packaging |
|
||||
| `frontend-next/` | the SPA it serves |
|
||||
| `internal/update/` | manifest, download, verify, apply — shared by the shell and the helper |
|
||||
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux, versioned install on Windows) |
|
||||
| `cmd/studio-manifest/`, `cmd/sign/`, `cmd/windows-resource/` | release-side tools |
|
||||
| `third_party/go-webview2/` | vendored fork; see below |
|
||||
| `cmd/studio-manifest/`, `cmd/sign/` | release-side tools |
|
||||
| `internal/winuninstall/` | taking a previous Windows install over |
|
||||
|
||||
The host binary itself lives in the main module at `cmd/reasonix-studio-host`,
|
||||
because the updater it carries has to stay in the kernel's language.
|
||||
|
||||
## Why a nested module
|
||||
|
||||
`desktop/` is its own Go module (`module reasonix/desktop`, `replace reasonix =>
|
||||
../`). That keeps the CGO + WebKit build entirely separate from the CLI's
|
||||
`CGO_ENABLED=0` single-static-binary guarantee: the parent module's `go build /
|
||||
vet / test ./...` skip this directory, while the import path stays under
|
||||
`reasonix/` so it can still import the `reasonix/internal/*` kernel.
|
||||
|
||||
## The vendored WebView2 fork
|
||||
|
||||
`replace github.com/wailsapp/go-webview2 => ./third_party/go-webview2` carries
|
||||
patches upstream does not have: mixed-DPI monitor-scale detection (#5862),
|
||||
`--no-proxy-server` so the loopback UI ignores stale system proxies, and native
|
||||
renderer-failure recovery. `next/webview2_patch_test.go` reads the fork's AST
|
||||
and fails if a Wails bump silently drops any of them.
|
||||
../`). The parent module's `go build / vet / test ./...` skip this directory
|
||||
while the import path stays under `reasonix/`, so it can still import the
|
||||
`reasonix/internal/*` kernel. The module is CGO-free, so no platform carries
|
||||
build dependencies of its own.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go (matches the parent module).
|
||||
- Node 24+ and **pnpm 10** (`npm install -g pnpm@10`).
|
||||
- Platform webview libs: macOS ships WebKit; Windows needs the Edge **WebView2**
|
||||
runtime; Linux needs `libgtk-3-dev` and `libwebkit2gtk-4.1-dev` (4.0 is gone
|
||||
from Ubuntu 24.04+ and Fedora 40+, so Linux builds always carry the
|
||||
`webkit2_41` tag).
|
||||
|
||||
No Wails CLI is required — `scripts/studio-build.sh` drives `go build` directly.
|
||||
|
||||
## Running it
|
||||
|
||||
@@ -72,10 +62,8 @@ No Wails CLI is required — `scripts/studio-build.sh` drives `go build` directl
|
||||
make studio # build the SPA + kernel and launch the Electron shell
|
||||
```
|
||||
|
||||
One launch path on every platform. Electron's own dev build is already an .app,
|
||||
so macOS treats it as a real GUI app and native panels — "Add a folder…" — open
|
||||
normally; the Wails shell needs a bundle of its own for that, which is what
|
||||
`desktop/next/run-studio.sh` still builds while it is still here.
|
||||
One launch path on every platform. The dev build is already an .app, so macOS
|
||||
treats it as a real GUI app and native panels — "Add a folder…" — open normally.
|
||||
|
||||
For frontend iteration, run the kernel and point Vite at it:
|
||||
|
||||
@@ -87,13 +75,10 @@ cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API path
|
||||
## Building and testing
|
||||
|
||||
```sh
|
||||
bash scripts/studio-build.sh --shell-only # compile the shell for this host
|
||||
bash scripts/studio-build.sh darwin/arm64 v0.1.0 # full artifact into dist/
|
||||
|
||||
make studio-test # go test ./... for this module
|
||||
cd frontend-next && pnpm typecheck && pnpm build
|
||||
cd frontend-next && pnpm typecheck && pnpm test && pnpm build
|
||||
```
|
||||
|
||||
Linux additionally builds a `.deb`; it is what makes Studio self-updating there,
|
||||
because the shared apply path stages single files and Studio ships a binary plus
|
||||
an SPA tree. See `docs/STUDIO_RELEASE.md`.
|
||||
Packaging runs through electron-builder; `release-studio.yml` drives it on all
|
||||
three platforms. Linux ships a `.deb` because that is what makes Studio
|
||||
self-updating there. See `docs/STUDIO_RELEASE.md`.
|
||||
@@ -1,232 +0,0 @@
|
||||
// Command windows-resource stamps Reasonix branding and metadata into a Windows
|
||||
// support executable after it has been built. Wails already uses the same
|
||||
// winres library for the desktop executable; keeping the support binaries on the
|
||||
// same resource path avoids generic Explorer, shortcut, and taskbar icons.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tc-hib/winres"
|
||||
"github.com/tc-hib/winres/version"
|
||||
)
|
||||
|
||||
const (
|
||||
companyName = "Reasonix"
|
||||
copyright = "Copyright © 2026 Reasonix Contributors"
|
||||
)
|
||||
|
||||
type resourceOptions struct {
|
||||
executable string
|
||||
icon string
|
||||
numericVersion string
|
||||
fileDescription string
|
||||
internalName string
|
||||
originalName string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "windows-resource:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
fs := flag.NewFlagSet("windows-resource", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
var opts resourceOptions
|
||||
fs.StringVar(&opts.executable, "exe", "", "Windows executable to stamp")
|
||||
fs.StringVar(&opts.icon, "icon", "", "ICO file to embed")
|
||||
fs.StringVar(&opts.numericVersion, "version", "", "numeric X.Y.Z version")
|
||||
fs.StringVar(&opts.fileDescription, "description", "", "Windows FileDescription")
|
||||
fs.StringVar(&opts.internalName, "internal-name", "", "Windows InternalName")
|
||||
fs.StringVar(&opts.originalName, "original-filename", "", "Windows OriginalFilename")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " "))
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"-exe": opts.executable,
|
||||
"-icon": opts.icon,
|
||||
"-version": opts.numericVersion,
|
||||
"-description": opts.fileDescription,
|
||||
"-internal-name": opts.internalName,
|
||||
"-original-filename": opts.originalName,
|
||||
} {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s is required", name)
|
||||
}
|
||||
}
|
||||
return stampExecutable(opts)
|
||||
}
|
||||
|
||||
func stampExecutable(opts resourceOptions) error {
|
||||
numericVersion, err := parseNumericVersion(opts.numericVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
iconFile, err := os.Open(opts.icon)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open icon: %w", err)
|
||||
}
|
||||
ico, err := winres.LoadICO(iconFile)
|
||||
closeErr := iconFile.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load icon: %w", err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close icon: %w", closeErr)
|
||||
}
|
||||
|
||||
rs := winres.ResourceSet{}
|
||||
if err := rs.SetIcon(winres.ID(1), ico); err != nil {
|
||||
return fmt.Errorf("set icon: %w", err)
|
||||
}
|
||||
rs.SetManifest(winres.AppManifest{
|
||||
Identity: winres.AssemblyIdentity{
|
||||
Name: "Reasonix." + opts.internalName,
|
||||
Version: numericVersion,
|
||||
},
|
||||
Description: opts.fileDescription,
|
||||
ExecutionLevel: winres.AsInvoker,
|
||||
DPIAwareness: winres.DPIPerMonitorV2,
|
||||
LongPathAware: true,
|
||||
UseCommonControlsV6: true,
|
||||
})
|
||||
|
||||
info := version.Info{
|
||||
FileVersion: numericVersion,
|
||||
ProductVersion: numericVersion,
|
||||
}
|
||||
for key, value := range map[string]string{
|
||||
version.CompanyName: companyName,
|
||||
version.FileDescription: opts.fileDescription,
|
||||
version.FileVersion: opts.numericVersion,
|
||||
version.InternalName: opts.internalName,
|
||||
version.LegalCopyright: copyright,
|
||||
version.OriginalFilename: opts.originalName,
|
||||
version.ProductName: "Reasonix",
|
||||
version.ProductVersion: opts.numericVersion,
|
||||
version.Comments: "Reasonix desktop support component.",
|
||||
} {
|
||||
if err := info.Set(version.LangDefault, key, value); err != nil {
|
||||
return fmt.Errorf("set version field %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
rs.SetVersionInfo(info)
|
||||
|
||||
source, err := os.ReadFile(opts.executable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read executable: %w", err)
|
||||
}
|
||||
var stamped bytes.Buffer
|
||||
if err := rs.WriteToEXE(&stamped, bytes.NewReader(source)); err != nil {
|
||||
return fmt.Errorf("write resources: %w", err)
|
||||
}
|
||||
if err := verifyResources(stamped.Bytes(), opts, numericVersion); err != nil {
|
||||
return fmt.Errorf("verify resources: %w", err)
|
||||
}
|
||||
|
||||
mode := os.FileMode(0o755)
|
||||
if stat, err := os.Stat(opts.executable); err == nil {
|
||||
mode = stat.Mode()
|
||||
}
|
||||
if err := replaceFile(opts.executable, stamped.Bytes(), mode); err != nil {
|
||||
return fmt.Errorf("replace executable: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseNumericVersion(value string) ([4]uint16, error) {
|
||||
parts := strings.Split(strings.TrimSpace(value), ".")
|
||||
if len(parts) != 3 && len(parts) != 4 {
|
||||
return [4]uint16{}, fmt.Errorf("version %q must have three or four numeric parts", value)
|
||||
}
|
||||
var parsed [4]uint16
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return [4]uint16{}, fmt.Errorf("version %q contains an empty part", value)
|
||||
}
|
||||
n, err := strconv.ParseUint(part, 10, 16)
|
||||
if err != nil {
|
||||
return [4]uint16{}, fmt.Errorf("version %q is not a Windows numeric version: %w", value, err)
|
||||
}
|
||||
parsed[i] = uint16(n)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func verifyResources(executable []byte, opts resourceOptions, numericVersion [4]uint16) error {
|
||||
rs, err := winres.LoadFromEXE(bytes.NewReader(executable))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := rs.GetIcon(winres.ID(1)); err != nil {
|
||||
return fmt.Errorf("application icon: %w", err)
|
||||
}
|
||||
manifest := rs.Get(winres.RT_MANIFEST, winres.ID(1), winres.LCIDDefault)
|
||||
if len(manifest) == 0 || !bytes.Contains(manifest, []byte(`requestedExecutionLevel level="asInvoker"`)) {
|
||||
return errors.New("asInvoker application manifest is missing")
|
||||
}
|
||||
versionData := rs.Get(winres.RT_VERSION, winres.ID(1), version.LangDefault)
|
||||
info, err := version.FromBytes(versionData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("version info: %w", err)
|
||||
}
|
||||
if info.FileVersion != numericVersion || info.ProductVersion != numericVersion {
|
||||
return fmt.Errorf("numeric version mismatch: file=%v product=%v want=%v", info.FileVersion, info.ProductVersion, numericVersion)
|
||||
}
|
||||
table := info.Table()[version.LangDefault]
|
||||
if table == nil {
|
||||
return errors.New("English version string table is missing")
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
version.CompanyName: companyName,
|
||||
version.FileDescription: opts.fileDescription,
|
||||
version.InternalName: opts.internalName,
|
||||
version.OriginalFilename: opts.originalName,
|
||||
version.ProductName: "Reasonix",
|
||||
} {
|
||||
if got := (*table)[key]; got != want {
|
||||
return fmt.Errorf("version field %s=%q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceFile(path string, data []byte, mode os.FileMode) error {
|
||||
temp, err := os.CreateTemp(filepath.Dir(path), ".reasonix-resource-*.exe")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempName := temp.Name()
|
||||
defer os.Remove(tempName)
|
||||
if err := temp.Chmod(mode); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tempName, path)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"debug/pe"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/tc-hib/winres"
|
||||
"github.com/tc-hib/winres/version"
|
||||
|
||||
"reasonix/internal/tempdir"
|
||||
)
|
||||
|
||||
func TestParseNumericVersion(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
want [4]uint16
|
||||
}{
|
||||
{"1.17.13", [4]uint16{1, 17, 13, 0}},
|
||||
{"2.3.4.5", [4]uint16{2, 3, 4, 5}},
|
||||
} {
|
||||
got, err := parseNumericVersion(test.value)
|
||||
if err != nil {
|
||||
t.Fatalf("parseNumericVersion(%q): %v", test.value, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("parseNumericVersion(%q) = %v, want %v", test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "1.2", "1.2.3-beta", "1.2.70000"} {
|
||||
if _, err := parseNumericVersion(value); err == nil {
|
||||
t.Errorf("parseNumericVersion(%q) unexpectedly succeeded", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStampExecutableSupportsWindowsReleaseArchitectures(t *testing.T) {
|
||||
icon, err := filepath.Abs(filepath.Join("..", "..", "next", "build", "windows", "icon.ico"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
arch string
|
||||
machine uint16
|
||||
}{
|
||||
{"amd64", pe.IMAGE_FILE_MACHINE_AMD64},
|
||||
{"arm64", pe.IMAGE_FILE_MACHINE_ARM64},
|
||||
} {
|
||||
t.Run(test.arch, func(t *testing.T) {
|
||||
dir := tempdir.New(t)
|
||||
source := filepath.Join(dir, "main.go")
|
||||
if err := os.WriteFile(source, []byte("package main\nfunc main() {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executable := filepath.Join(dir, "reasonix-launcher.exe")
|
||||
cmd := exec.Command("go", "build", "-trimpath", "-o", executable, source)
|
||||
cmd.Env = append(os.Environ(), "GOOS=windows", "GOARCH="+test.arch, "CGO_ENABLED=0")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("cross-build %s fixture: %v\n%s", test.arch, err, output)
|
||||
}
|
||||
|
||||
opts := resourceOptions{
|
||||
executable: executable,
|
||||
icon: icon,
|
||||
numericVersion: "1.17.13",
|
||||
fileDescription: "Reasonix Launcher",
|
||||
internalName: "reasonix-launcher",
|
||||
originalName: "reasonix-launcher.exe",
|
||||
}
|
||||
if err := stampExecutable(opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
peFile, err := pe.Open(executable)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if peFile.Machine != test.machine {
|
||||
t.Errorf("PE machine = %#x, want %#x", peFile.Machine, test.machine)
|
||||
}
|
||||
peFile.Close()
|
||||
|
||||
data, err := os.ReadFile(executable)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rs, err := winres.LoadFromEXE(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rs.GetIcon(winres.ID(1)); err != nil {
|
||||
t.Fatalf("embedded icon: %v", err)
|
||||
}
|
||||
info, err := version.FromBytes(rs.Get(winres.RT_VERSION, winres.ID(1), version.LangDefault))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := (*info.Table()[version.LangDefault])[version.OriginalFilename]; got != "reasonix-launcher.exe" {
|
||||
t.Errorf("OriginalFilename = %q", got)
|
||||
}
|
||||
manifest := rs.Get(winres.RT_MANIFEST, winres.ID(1), winres.LCIDDefault)
|
||||
if !bytes.Contains(manifest, []byte(`dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,system`)) {
|
||||
t.Errorf("per-monitor-v2 manifest setting is missing: %s", manifest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceFilePreservesMode(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not expose Unix executable mode bits")
|
||||
}
|
||||
path := filepath.Join(tempdir.New(t), "program.exe")
|
||||
if err := os.WriteFile(path, []byte("old"), 0o751); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := replaceFile(path, []byte("new"), 0o751); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := stat.Mode().Perm(); got != 0o751 {
|
||||
t.Fatalf("mode = %#o, want 0751", got)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
// The window the client expects, installed before anything imports it. Only the
|
||||
// bus is real: the probe plays the part the desktop shell plays, forwarding the
|
||||
// kernel's own frames onto a channel that has no reconnect — which is the
|
||||
// transport the bootstrap was written for.
|
||||
// stream is real: the probe plays the part the server plays, forwarding the
|
||||
// kernel's own frames onto a transport that never reconnects — which is the
|
||||
// one the bootstrap was written for.
|
||||
const listeners = new Set<(raw: string) => void>();
|
||||
|
||||
export function deliver(raw: string) {
|
||||
for (const cb of listeners) cb(raw);
|
||||
}
|
||||
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
runtime: {
|
||||
EventsOn(_name: string, cb: (raw: string) => void) {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
},
|
||||
},
|
||||
};
|
||||
class ShimEventSource {
|
||||
onmessage: ((m: { data: string }) => void) | null = null;
|
||||
private readonly feed = (raw: string) => this.onmessage?.({ data: raw });
|
||||
|
||||
constructor() {
|
||||
listeners.add(this.feed);
|
||||
}
|
||||
|
||||
close() {
|
||||
listeners.delete(this.feed);
|
||||
}
|
||||
}
|
||||
|
||||
(globalThis as { window?: unknown }).window = {};
|
||||
(globalThis as { EventSource?: unknown }).EventSource = ShimEventSource;
|
||||
@@ -110,7 +110,7 @@ pick().then(
|
||||
// Before the first render, and not from whichever view happens to want a
|
||||
// drop: a window with no drop target mounted still has to refuse a file, or
|
||||
// the webview navigates to it and the app is replaced by what was dropped.
|
||||
installFileDrop(hub);
|
||||
installFileDrop();
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App hub={hub} />
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// What a window can do and a page cannot, behind one interface so nothing in
|
||||
// the app learns which shell it is running in. Wails publishes bound methods on
|
||||
// window.go; Electron exposes a preload bridge; a browser tab has neither, and
|
||||
// answers for itself.
|
||||
// the app learns which shell it is running in. Electron exposes a preload
|
||||
// bridge; a browser tab has none, and answers for itself.
|
||||
|
||||
export type Shell = "wails" | "electron" | "browser";
|
||||
export type Shell = "electron" | "browser";
|
||||
|
||||
export interface HostInfo {
|
||||
shell: Shell;
|
||||
@@ -22,7 +21,7 @@ export interface HostPort {
|
||||
closeWindow(): void;
|
||||
openExternal(url: string): void;
|
||||
/** Where dropped files live. Empty where the shell cannot say — a browser
|
||||
* tab never learns a path, and Wails reports them on its own channel. */
|
||||
* tab never learns a path. */
|
||||
pathsForFiles(files: File[]): string[];
|
||||
/** Put text on disk where the user picks. null means this shell has no save
|
||||
* surface at all; "" means they dismissed the dialog, which is an answer. */
|
||||
@@ -52,28 +51,10 @@ interface ElectronBridge {
|
||||
pickFolder(startIn: string): Promise<string>;
|
||||
}
|
||||
|
||||
interface WailsShell {
|
||||
runtime?: { Environment?: () => Promise<{ platform?: string }> };
|
||||
go?: {
|
||||
main?: {
|
||||
App?: {
|
||||
MinimiseWindow?: () => Promise<void>;
|
||||
ToggleMaximiseWindow?: () => Promise<void>;
|
||||
IsWindowMaximised?: () => Promise<boolean>;
|
||||
CloseWindow?: () => Promise<void>;
|
||||
OpenExternal?: (url: string) => Promise<void>;
|
||||
SaveText?: (name: string, content: string) => Promise<string>;
|
||||
PickWorkspace?: () => Promise<string>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const bridge = () => (window as unknown as { reasonixHost?: ElectronBridge }).reasonixHost;
|
||||
const wails = () => (window as unknown as WailsShell).go?.main?.App;
|
||||
|
||||
// Electron reports the Go name for two of the three; the page has always spelled
|
||||
// them the way Wails does, and one spelling is what keeps a CSS selector honest.
|
||||
// Electron reports the platform under Node's names; the page spells them the
|
||||
// way Go does, and one spelling is what keeps a CSS selector honest.
|
||||
function normalise(platform: string): string {
|
||||
if (platform === "win32") return "windows";
|
||||
return platform;
|
||||
@@ -119,49 +100,6 @@ class ElectronHost implements HostPort {
|
||||
}
|
||||
}
|
||||
|
||||
class WailsHost implements HostPort {
|
||||
async describe(): Promise<HostInfo> {
|
||||
const env = await (window as unknown as WailsShell).runtime?.Environment?.().catch(() => undefined);
|
||||
return { shell: "wails", platform: normalise(env?.platform ?? ""), titleBar: true };
|
||||
}
|
||||
minimiseWindow() {
|
||||
void wails()?.MinimiseWindow?.();
|
||||
}
|
||||
toggleMaximiseWindow() {
|
||||
void wails()?.ToggleMaximiseWindow?.();
|
||||
}
|
||||
isWindowMaximised() {
|
||||
return wails()?.IsWindowMaximised?.().catch(() => false) ?? Promise.resolve(false);
|
||||
}
|
||||
closeWindow() {
|
||||
void wails()?.CloseWindow?.();
|
||||
}
|
||||
openExternal(url: string) {
|
||||
void wails()?.OpenExternal?.(url);
|
||||
}
|
||||
// The paths arrive on the shell's own drop channel instead, which is why
|
||||
// nothing here can answer for a file the page is holding.
|
||||
pathsForFiles() {
|
||||
return [];
|
||||
}
|
||||
async saveText(name: string, content: string) {
|
||||
return (await wails()?.SaveText?.(name, content)) ?? null;
|
||||
}
|
||||
// Packing and saving are one binding in this shell; the caller reaches it
|
||||
// rather than assembling the bytes itself.
|
||||
saveBytes() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
// The Go side titles the panel and opens it on the running workspace, which
|
||||
// it reads from the kernel directly, so startIn is the page telling this
|
||||
// shell something it already knows.
|
||||
async pickFolder() {
|
||||
return (await wails()?.PickWorkspace?.()) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// A tab has no window of its own to drive: the chrome that would call these is
|
||||
// not rendered there, and a link opens the way a link always has.
|
||||
class BrowserHost implements HostPort {
|
||||
describe() {
|
||||
return Promise.resolve({ shell: "browser" as const, platform: "", titleBar: false });
|
||||
@@ -192,7 +130,6 @@ class BrowserHost implements HostPort {
|
||||
function pick(): HostPort {
|
||||
const api = bridge();
|
||||
if (api) return new ElectronHost(api);
|
||||
if ((window as unknown as WailsShell).runtime?.Environment) return new WailsHost();
|
||||
return new BrowserHost();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The folder picker is the first capability to cross from a Wails binding to
|
||||
// the shell port, so what is held here is that each shell answers it its own
|
||||
// way and that the three answers stay apart: a path, "" for a dismissed panel,
|
||||
// and null for a shell with no picker at all. A caller that conflates the last
|
||||
// two asks again and the panel opens twice.
|
||||
// What is held here is that the three answers a picker can give stay apart: a
|
||||
// path, "" for a dismissed panel, and null for a shell with no picker at all.
|
||||
// A caller that conflates the last two asks again and the panel opens twice.
|
||||
|
||||
const workspaceInfo = (current: string) => ({ current, canSwitch: true, canIsolate: false, recents: [] });
|
||||
|
||||
@@ -69,25 +67,6 @@ describe("asking for a folder", () => {
|
||||
expect(opened).toEqual([""]);
|
||||
});
|
||||
|
||||
it("reaches the Wails binding, which resolves its own starting folder", async () => {
|
||||
let called = 0;
|
||||
const port = await portOver({
|
||||
runtime: { Environment: async () => ({ platform: "darwin" }) },
|
||||
go: {
|
||||
main: {
|
||||
App: {
|
||||
PickWorkspace: async () => {
|
||||
called++;
|
||||
return "/from-wails";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await port.pickFolder()).toBe("/from-wails");
|
||||
expect(called).toBe(1);
|
||||
});
|
||||
|
||||
it("answers null in a browser tab, which has no panel to open", async () => {
|
||||
const port = await portOver({});
|
||||
expect(await port.pickFolder()).toBeNull();
|
||||
|
||||
@@ -105,12 +105,6 @@ export interface HubPort {
|
||||
// Absolute paths of every file dropped anywhere on the window. It belongs
|
||||
// here rather than on a pane because the window has one of it: the shell
|
||||
// registers its drop listener once and ignores a second call, so a per-pane
|
||||
// subscription would quietly serve whichever pane opened first. Which element
|
||||
// the drop landed on is decided in the page against the DOM — the shell can
|
||||
// only offer coordinates, and coordinates part ways with CSS pixels under an
|
||||
// interface zoom. A browser tab never learns a path; there this unsubscribes
|
||||
// from nothing and dropped bytes go to AgentPort.attach instead.
|
||||
onDroppedPaths(cb: (paths: string[]) => void): () => void;
|
||||
portFor(rt: RuntimeView): AgentPort;
|
||||
}
|
||||
|
||||
@@ -329,10 +323,6 @@ export class SseHub implements HubPort {
|
||||
return this.shell.pickFolder();
|
||||
}
|
||||
|
||||
onDroppedPaths(cb: (paths: string[]) => void) {
|
||||
return this.shell.onDroppedPaths(cb);
|
||||
}
|
||||
|
||||
portFor(rt: RuntimeView): AgentPort {
|
||||
const held = this.ports.get(rt.id);
|
||||
if (held) return held;
|
||||
|
||||
@@ -249,11 +249,6 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// Both of these are the shell reporting on itself; the fixture has no shell.
|
||||
onDroppedPaths(): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
async account(): Promise<AccountState> {
|
||||
return { signedIn: false };
|
||||
}
|
||||
|
||||
@@ -199,11 +199,6 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve<string | null>("~/projects/mock-workspace");
|
||||
}
|
||||
|
||||
// The fixture has no shell to report a path, so a drop there carries bytes.
|
||||
onDroppedPaths(): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
portFor(rt: RuntimeView): AgentPort {
|
||||
const held = this.ports.get(rt.id);
|
||||
if (held) return held;
|
||||
|
||||
@@ -234,7 +234,7 @@ export interface JobEntry {
|
||||
|
||||
// The UI depends on this and nothing else. SsePort talks to internal/serve;
|
||||
// MockPort replays a fixture. Neither is allowed to leak transport details
|
||||
// upward, which is what keeps the same UI usable in a browser and in Wails.
|
||||
// upward, which is what keeps the same UI usable in a browser and in a shell.
|
||||
export interface SessionEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SsePort } from "./sse";
|
||||
import type { WireEvent } from "./wire";
|
||||
|
||||
// Driven through the shell's bus, the transport with no reconnect to hide
|
||||
// behind: every frame is handed over by hand, so a gap is only ever closed by
|
||||
// the logic under test.
|
||||
// Driven through a stand-in EventSource that never reconnects on its own:
|
||||
// every frame is handed over by hand, so a gap is only ever closed by the
|
||||
// logic under test rather than by the transport resuming.
|
||||
type Feed = (raw: string) => void;
|
||||
type ReplyBody = { frames?: unknown[]; complete?: boolean } | null;
|
||||
|
||||
@@ -17,17 +17,18 @@ function attach(
|
||||
const asked: string[] = [];
|
||||
let gaps = 0;
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
runtime: {
|
||||
EventsOn: (_name: string, cb: Feed) => {
|
||||
feed = cb;
|
||||
return () => {};
|
||||
},
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
onmessage: ((m: { data: string }) => void) | null = null;
|
||||
constructor() {
|
||||
feed = (raw: string) => this.onmessage?.({ data: raw });
|
||||
}
|
||||
close() {}
|
||||
},
|
||||
});
|
||||
);
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
asked.push(url);
|
||||
if (url.includes("/rx-replay")) return { ok: true, json: async () => ({}) };
|
||||
const body = reply(Number(new URL(url, "http://x").searchParams.get("lastEventId") ?? 0));
|
||||
if (!body) return { ok: false, status: 500, json: async () => ({}) };
|
||||
return { ok: true, json: async () => body };
|
||||
|
||||
@@ -2,40 +2,19 @@ import { PLAN_ACTIONS, type PlanAction } from "./session";
|
||||
import type { AccountState, AgentPort, Appearance, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageReport, McpDraft, PluginExport, Queue, Queued, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import { HttpError, type Attachment, type ChangeDiff, type DroppedRef, type WorkspaceChanges } from "./port";
|
||||
import { SseTheme } from "./sse_theme";
|
||||
import type { WailsBind } from "./wails";
|
||||
import type { StoragePlan, StorageState } from "./storage";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { host } from "./host";
|
||||
|
||||
// The running project is the default, so its requests stay the bare path they
|
||||
// have always been and only a cross-project read carries the folder.
|
||||
// Must match wailsEventName / replayPath in desktop/next.
|
||||
const WAILS_EVENT = "rx:event";
|
||||
// Install progress rides its own channel: it is the shell reporting on itself,
|
||||
// not something the kernel emitted into the conversation.
|
||||
// Fast enough that a download bar moves, slow enough that an open panel is not
|
||||
// a load. The read is one small JSON body and answers from memory.
|
||||
const UPDATE_POLL_MS = 500;
|
||||
const WAILS_REPLAY = "/rx-replay";
|
||||
|
||||
interface WailsBus {
|
||||
EventsOn(name: string, cb: (data: string) => void): () => void;
|
||||
}
|
||||
|
||||
|
||||
// Wails' own drop API: the only channel that reports where a dropped file
|
||||
// lives. Absent in a browser tab, where a page never learns a path.
|
||||
interface WailsFileDropBus {
|
||||
OnFileDrop(cb: (x: number, y: number, paths: string[]) => void, useDropTarget: boolean): void;
|
||||
}
|
||||
|
||||
// Wails publishes bound methods at window.go.<package>.<Struct>.<Method>; the
|
||||
// shell's package is main and the struct is App. Absent in a browser tab.
|
||||
|
||||
export class SsePort extends SseTheme implements AgentPort {
|
||||
private readonly dropSubs = new Set<(paths: string[]) => void>();
|
||||
private dropWired = false;
|
||||
|
||||
status() {
|
||||
return this.get<SessionStatus>("/status");
|
||||
}
|
||||
@@ -71,11 +50,6 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
}
|
||||
|
||||
async exportPlugin(name: string): Promise<PluginExport> {
|
||||
const save = (window as WailsBind).go?.main?.App?.SavePluginExport;
|
||||
if (save) {
|
||||
const out = await save(name);
|
||||
return { required: out.required ?? [], savedTo: out.path || undefined };
|
||||
}
|
||||
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name) + "/export", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
@@ -290,29 +264,6 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
};
|
||||
}
|
||||
|
||||
// Wails registers its drop listeners once and ignores a second call, so the
|
||||
// one subscription is fanned out here. useDropTarget=false because the filter
|
||||
// it offers is the wrong one: it hit-tests the drop coordinates against the
|
||||
// CSS opt-in, and those coordinates are native pixels, which stop agreeing
|
||||
// with CSS pixels the moment the interface is zoomed. The page routes the
|
||||
// drop against the DOM instead, where the element under the pointer is a
|
||||
// fact rather than an arithmetic result.
|
||||
// A connect is blocked while one of these is on screen, so there is no
|
||||
// polling to fall back on: no bus means no window, and no window means the
|
||||
// kernel refused the question rather than asking it.
|
||||
onDroppedPaths(cb: (paths: string[]) => void): () => void {
|
||||
const rt = (window as unknown as { runtime?: WailsFileDropBus }).runtime;
|
||||
if (!rt?.OnFileDrop) return () => {};
|
||||
if (!this.dropWired) {
|
||||
this.dropWired = true;
|
||||
rt.OnFileDrop((_x, _y, paths) => this.dropSubs.forEach((f) => f(paths ?? [])), false);
|
||||
}
|
||||
this.dropSubs.add(cb);
|
||||
return () => {
|
||||
this.dropSubs.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
account() {
|
||||
return this.get<AccountState>("/account");
|
||||
}
|
||||
@@ -593,25 +544,12 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
// A malformed frame must not tear down the stream.
|
||||
}
|
||||
};
|
||||
// Wails' asset server buffers a response until its handler returns, so the
|
||||
// SSE stream never reaches the page inside the shell. There it pushes the
|
||||
// same frames over its own bus; the payload is identical.
|
||||
const bus = (window as unknown as { runtime?: WailsBus }).runtime;
|
||||
let detach: () => void;
|
||||
if (bus?.EventsOn) {
|
||||
const off = bus.EventsOn(this.rt ? `${WAILS_EVENT}:${this.rt}` : WAILS_EVENT, feed);
|
||||
// Subscribing to the bus is not the handshake /events is: ask the shell
|
||||
// to replay whatever prompt is already waiting for an answer.
|
||||
void fetch(this.base + WAILS_REPLAY, { method: "POST", headers: { "content-type": "application/json" } }).catch(() => {});
|
||||
detach = off;
|
||||
} else {
|
||||
// EventSource resumes on its own: it reconnects carrying the last id it
|
||||
// saw, and the server replays from there. Recovery here is for the frames
|
||||
// shed without the connection ever dropping.
|
||||
const es = new EventSource(this.base + "/events", { withCredentials: true });
|
||||
es.onmessage = (m) => feed(m.data);
|
||||
detach = () => es.close();
|
||||
}
|
||||
// EventSource resumes on its own: it reconnects carrying the last id it
|
||||
// saw, and the server replays from there. Recovery here is for the frames
|
||||
// shed without the connection ever dropping.
|
||||
const es = new EventSource(this.base + "/events", { withCredentials: true });
|
||||
es.onmessage = (m) => feed(m.data);
|
||||
const detach = () => es.close();
|
||||
if (bootstrap) void bootstrapFrom(bootstrap);
|
||||
return () => {
|
||||
live = false;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { SseLook } from "./sse_look";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog } from "./port";
|
||||
import { rootQuery } from "./sse_http";
|
||||
import type { WailsBind } from "./wails";
|
||||
import { download } from "./download";
|
||||
import { host } from "./host";
|
||||
|
||||
@@ -36,16 +35,9 @@ export class SseExtensions extends SseLook {
|
||||
return this.del0<PluginPlan>("/plugins/" + encodeURIComponent(name));
|
||||
}
|
||||
|
||||
// A webview starts no downloads of its own, so the shell writes the file
|
||||
// through its own save dialog when there is one. In a browser tab the archive
|
||||
// is an ordinary download, and the header is read first because the body is
|
||||
// bytes and has nowhere to say what was stripped out of it.
|
||||
// The header is read before the body because the body is bytes and has
|
||||
// nowhere to say what was stripped out of it.
|
||||
async exportPlugin(name: string): Promise<PluginExport> {
|
||||
const save = (window as WailsBind).go?.main?.App?.SavePluginExport;
|
||||
if (save) {
|
||||
const out = await save(name);
|
||||
return { required: out.required ?? [], savedTo: out.path || undefined };
|
||||
}
|
||||
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name) + "/export", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// What the desktop shell binds onto the window. The browser build has none
|
||||
// of it, so every call site asks before using one — the same page runs in
|
||||
// both places and only one of them has a shell underneath.
|
||||
export interface WailsBind {
|
||||
go?: {
|
||||
main?: {
|
||||
App?: {
|
||||
SavePluginExport?: (name: string) => Promise<{ path: string; required: string[] }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -70,10 +70,9 @@ rules are written in.
|
||||
|
||||
## Title bar and chrome
|
||||
|
||||
- Two spellings, because the shells disagree: `--wails-draggable` is Wails' own
|
||||
property and `-webkit-app-region` is Chromium's. Inside the shell this row *is*
|
||||
the title bar, so it has to be the drag handle, and every control on it opts
|
||||
back out. Membership is what the markup says is interactive, not a list of the
|
||||
- `-webkit-app-region` is Chromium's drag property. Inside the shell this row
|
||||
*is* the title bar, so it has to be the drag handle, and every control on it
|
||||
opts back out. Membership is what the markup says is interactive, not a list of the
|
||||
controls that happen to be on the bar today.
|
||||
- macOS floats its window controls over the corner at a y AppKit owns — measured
|
||||
centre 25.5px. The reserved 78px is what the breadcrumb starts after, and the
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
flex: none; background: color-mix(in srgb, var(--surface) 72%, var(--page)); border-bottom: 1px solid var(--border); box-shadow: 0 1px 0 rgb(0 0 0 / .025);
|
||||
}
|
||||
|
||||
[data-titlebar="app"] .chrome { --wails-draggable: drag; -webkit-app-region: drag }
|
||||
[data-titlebar="app"] .chrome { -webkit-app-region: drag }
|
||||
[data-titlebar="app"] .chrome
|
||||
*:not(:is(button, a[href], input, select, textarea, summary, .picker,
|
||||
[role="button"], [role="tab"], [role="menuitem"], [role="switch"]) *) {
|
||||
@@ -165,7 +165,6 @@
|
||||
[data-titlebar="app"] .chrome
|
||||
:is(button, a[href], input, select, textarea, summary, .picker,
|
||||
[role="button"], [role="tab"], [role="menuitem"], [role="switch"]) {
|
||||
--wails-draggable: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
// Wails serves the window over a custom scheme on macOS and Linux, so those two
|
||||
// hosts are not a secure context and navigator.clipboard is undefined there.
|
||||
// execCommand is deprecated and is still the only path they have.
|
||||
// navigator.clipboard exists only in a secure context, which a browser reaching
|
||||
// `reasonix serve` over a plain address is not. execCommand is deprecated and
|
||||
// is still the only path left there.
|
||||
async function write(text: string) {
|
||||
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text);
|
||||
const carrier = document.createElement("textarea");
|
||||
|
||||
@@ -151,9 +151,9 @@ export function PaneTabs({ tabs, active, showRoot, onFocus, onClose, onRename }:
|
||||
onClick={() => onFocus(rt.id)}
|
||||
onDoubleClick={() => setEditing(rt.id)}
|
||||
onContextMenu={(ev) => {
|
||||
// 这个窗口的复制粘贴只有系统右键菜单一个来源(Wails 的
|
||||
// EnableDefaultContextMenu 就是为此开的),所以输入框里和有选区时
|
||||
// 一律让位 —— 自定义菜单只是快捷方式,不该抢走唯一的那条路。
|
||||
// The system context menu is this window's only copy/paste route,
|
||||
// so a field or a live selection yields to it: the custom menu is a
|
||||
// shortcut and must not take the only path away.
|
||||
const el = ev.target as HTMLElement;
|
||||
if (el.closest("input, textarea") || document.getSelection()?.isCollapsed === false) return;
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { HubPort } from "../port/hub";
|
||||
import { host } from "../port/host";
|
||||
|
||||
// What a drop hands over depends on which shell the page is in, and the two
|
||||
@@ -122,7 +121,7 @@ function light(next: Zone | null, dt: DataTransfer | null = null) {
|
||||
// no zone mounted still has to refuse a file, or the webview navigates to it and
|
||||
// the app is replaced by whatever was dropped. Nothing else in the window
|
||||
// prevents that default, and there is no way back short of a reload.
|
||||
export function install(hub: HubPort) {
|
||||
export function install() {
|
||||
if (router) return;
|
||||
router = createDropRouter<Zone>((zone, d) => zone.onDrop(d), {
|
||||
now: () => Date.now(),
|
||||
@@ -162,16 +161,13 @@ export function install(hub: HubPort) {
|
||||
const files = [...(dt?.files ?? [])];
|
||||
const text = files.length > 0 ? undefined : dt?.getData("text/uri-list") || dt?.getData("text/plain") || undefined;
|
||||
router?.dropped(zone, files, text);
|
||||
// A shell that can place the files answers here and now; the grace period
|
||||
// is for the one that reports them on a channel of its own.
|
||||
// A shell that can place the files answers here and now; the grace
|
||||
// period is what a browser tab, which never learns a path, waits out.
|
||||
const placed = host().pathsForFiles(files);
|
||||
if (placed.length > 0) router?.paths(placed);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// Absent in a browser tab, where the grace period is the only path taken.
|
||||
hub.onDroppedPaths((paths) => router?.paths(paths));
|
||||
}
|
||||
|
||||
// useFileDrop makes one element the target of a drop, and answers with the ref
|
||||
|
||||
@@ -75,11 +75,6 @@ const DECLARED_NON_MUTATION = {
|
||||
"POST /mcp/parse": { class: "pure-parse", why:
|
||||
"internal/serve/catalog.go's mcpParse decodes the body, calls mcpsetup.Parse, assembles servers and " +
|
||||
"risks and writes them back; internal/mcpsetup performs no write at all — no file, no store." },
|
||||
"POST /rx-replay": { class: "attach-handshake", why:
|
||||
"A bus subscription is not a request and cannot carry one, so the bus branch sends this; " +
|
||||
"desktop/next/main.go answers it with Controller.ReplayPendingPromptsWith — a read of the pending " +
|
||||
"prompts and an emit to the sink just attached, which is the same host operation GET /events " +
|
||||
"performs inline on the EventSource branch (internal/serve/eventstream.go)." },
|
||||
},
|
||||
// The fixture tree declares its own, so the rule is exercised rather than the
|
||||
// product's vocabulary being borrowed into a test.
|
||||
|
||||
@@ -4,88 +4,33 @@ go 1.26.0
|
||||
|
||||
toolchain go1.26.8
|
||||
|
||||
// The desktop shell is a nested module so its CGO/WebKit build never touches the
|
||||
// CLI's CGO_ENABLED=0 single-static-binary guarantee. The replace lets it import
|
||||
// the same reasonix/internal/* kernel (the import path stays under reasonix/, so
|
||||
// the internal rule still permits it). `go mod tidy` here resolves Wails + its
|
||||
// transitive deps; the parent module's go build/test ./... skips this directory.
|
||||
// The desktop tools are a nested module so their CGO build never touches the
|
||||
// CLI's CGO_ENABLED=0 single-static-binary guarantee. The replace lets them
|
||||
// import the same reasonix/internal/* kernel (the import path stays under
|
||||
// reasonix/, so the internal rule still permits it); the parent module's
|
||||
// go build/test ./... skips this directory.
|
||||
require reasonix v0.0.0
|
||||
|
||||
require (
|
||||
aead.dev/minisign v0.3.0
|
||||
fyne.io/systray v1.12.2
|
||||
github.com/tc-hib/winres v0.3.1
|
||||
github.com/wailsapp/wails/v2 v2.15.0
|
||||
golang.org/x/mod v0.40.0
|
||||
golang.org/x/sys v0.47.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/labstack/echo/v4 v4.15.2 // indirect
|
||||
github.com/labstack/gommon v0.5.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-pointer v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.28 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pkg/sftp v1.13.11 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
|
||||
github.com/samber/lo v1.53.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/tree-sitter/go-tree-sitter v0.25.0 // indirect
|
||||
github.com/tree-sitter/tree-sitter-javascript v0.25.0 // indirect
|
||||
github.com/tree-sitter/tree-sitter-python v0.25.0 // indirect
|
||||
github.com/tree-sitter/tree-sitter-rust v0.24.2 // indirect
|
||||
github.com/tree-sitter/tree-sitter-typescript v0.23.2 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/yuin/goldmark v1.8.5 // indirect
|
||||
github.com/zalando/go-keyring v0.2.8 // indirect
|
||||
golang.org/x/crypto v0.56.0 // indirect
|
||||
golang.org/x/image v0.45.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.75.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
modernc.org/sqlite v1.58.0 // indirect
|
||||
mvdan.cc/sh/v3 v3.14.0 // indirect
|
||||
)
|
||||
|
||||
replace reasonix => ../
|
||||
|
||||
// Reasonix patches WebView2 monitor-scale detection for mixed-DPI restore
|
||||
// (#5862) and isolates its embedded/loopback UI from stale system proxies.
|
||||
replace github.com/wailsapp/go-webview2 => ./third_party/go-webview2
|
||||
@@ -1,222 +1,68 @@
|
||||
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
|
||||
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
|
||||
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
|
||||
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
|
||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
|
||||
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
|
||||
github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
|
||||
github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
|
||||
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4=
|
||||
github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU=
|
||||
github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU=
|
||||
github.com/tree-sitter/tree-sitter-c v0.23.4 h1:nBPH3FV07DzAD7p0GfNvXM+Y7pNIoPenQWBpvM++t4c=
|
||||
github.com/tree-sitter/tree-sitter-c v0.23.4/go.mod h1:MkI5dOiIpeN94LNjeCp8ljXN/953JCwAby4bClMr6bw=
|
||||
github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJTJFCyFIeZU/8w=
|
||||
github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8=
|
||||
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2 h1:nFkkH6Sbe56EXLmZBqHHcamTpmz3TId97I16EnGy4rg=
|
||||
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2/go.mod h1:HNPOhN0qF3hWluYLdxWs5WbzP/iE4aaRVPMsdxuzIaQ=
|
||||
github.com/tree-sitter/tree-sitter-go v0.23.4 h1:yt5KMGnTHS+86pJmLIAZMWxukr8W7Ae1STPvQUuNROA=
|
||||
github.com/tree-sitter/tree-sitter-go v0.23.4/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040=
|
||||
github.com/tree-sitter/tree-sitter-html v0.23.2 h1:1UYDV+Yd05GGRhVnTcbP58GkKLSHHZwVaN+lBZV11Lc=
|
||||
github.com/tree-sitter/tree-sitter-html v0.23.2/go.mod h1:gpUv/dG3Xl/eebqgeYeFMt+JLOY9cgFinb/Nw08a9og=
|
||||
github.com/tree-sitter/tree-sitter-java v0.23.5 h1:J9YeMGMwXYlKSP3K4Us8CitC6hjtMjqpeOf2GGo6tig=
|
||||
github.com/tree-sitter/tree-sitter-java v0.23.5/go.mod h1:NRKlI8+EznxA7t1Yt3xtraPk1Wzqh3GAIC46wxvc320=
|
||||
github.com/tree-sitter/tree-sitter-javascript v0.25.0 h1:ZkWETb66/w8cc13yhfnNuHOLDQWl3BnKlH6f9AdR88c=
|
||||
github.com/tree-sitter/tree-sitter-javascript v0.25.0/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs=
|
||||
github.com/tree-sitter/tree-sitter-json v0.24.8 h1:tV5rMkihgtiOe14a9LHfDY5kzTl5GNUYe6carZBn0fQ=
|
||||
github.com/tree-sitter/tree-sitter-json v0.24.8/go.mod h1:F351KK0KGvCaYbZ5zxwx/gWWvZhIDl0eMtn+1r+gQbo=
|
||||
github.com/tree-sitter/tree-sitter-php v0.23.11 h1:iHewsLNDmznh8kgGyfWfujsZxIz1YGbSd2ZTEM0ZiP8=
|
||||
github.com/tree-sitter/tree-sitter-php v0.23.11/go.mod h1:T/kbfi+UcCywQfUNAJnGTN/fMSUjnwPXA8k4yoIks74=
|
||||
github.com/tree-sitter/tree-sitter-python v0.25.0 h1:O6XD9v8U1LOcRc3cNj9nM7XufrtEBezE6VrpRrHZDf0=
|
||||
github.com/tree-sitter/tree-sitter-python v0.25.0/go.mod h1:cpdthSy/Yoa28aJFBscFHlGiU+cnSiSh1kuDVtI8YeM=
|
||||
github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV6z8Qw8ai+72bYo=
|
||||
github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA=
|
||||
github.com/tree-sitter/tree-sitter-rust v0.24.2 h1:NL4nF67ib21RMzzfvkmXlVwe45vvhW10DVyO+D0z/W0=
|
||||
github.com/tree-sitter/tree-sitter-rust v0.24.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI=
|
||||
github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU=
|
||||
github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.15.0 h1:u7cHK+UesZOlYxyJxfYLteaCPhws6UsZoDdqUejuX6Q=
|
||||
github.com/wailsapp/wails/v2 v2.15.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
|
||||
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
|
||||
modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
mvdan.cc/sh/v3 v3.14.0 h1:wsPjcfrSXLJomdxwhVZqZ5j5Cue68l549Aj/DCqzbpc=
|
||||
mvdan.cc/sh/v3 v3.14.0/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleName</key><string>Reasonix Studio</string>
|
||||
<key>CFBundleExecutable</key><string>ReasonixStudio</string>
|
||||
<key>CFBundleIdentifier</key><string>io.reasonix.studio</string>
|
||||
<key>CFBundleIconFile</key><string>appicon</string>
|
||||
<key>CFBundleShortVersionString</key><string>@VERSION@</string>
|
||||
<key>CFBundleVersion</key><string>@NUMVER@</string>
|
||||
<key>LSMinimumSystemVersion</key><string>@MACOSMIN@</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Reasonix opens workspaces on other machines over SSH, including ones on this network.</string>
|
||||
<key>NSHighResolutionCapable</key><string>true</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,34 +0,0 @@
|
||||
// app_identity.go — the identity Windows groups this window's taskbar button
|
||||
// and routes its notifications by.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"reasonix/internal/appidentity"
|
||||
"reasonix/internal/installlayout"
|
||||
)
|
||||
|
||||
// applyAppIdentity stamps this process with the AppUserModelID and makes the
|
||||
// installed shortcuts carry the same one. Windows routes a toast through the
|
||||
// Start Menu shortcut that matches, so a shortcut left on an older identity —
|
||||
// an upgrade, a repaired install — silently swallows every notification. Both
|
||||
// calls are no-ops off Windows.
|
||||
func applyAppIdentity(logs io.Writer) {
|
||||
if err := appidentity.ApplyToCurrentProcess(); err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-next: apply app identity:", err)
|
||||
}
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
installRoot, err := installlayout.ResolveInstallRoot(executable)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := appidentity.RepairOwnedShortcuts(installRoot); err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-next: repair shortcut identity:", err)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// macOS reserves a margin around a dock icon; artwork that fills the frame
|
||||
// renders visibly larger than every system icon beside it.
|
||||
func TestDarwinICNSUsesMacOSIconSafeArea(t *testing.T) {
|
||||
img := decodeICNSImage(t, "appicon.icns", "ic10")
|
||||
if got, want := img.Bounds(), image.Rect(0, 0, 1024, 1024); got != want {
|
||||
t.Fatalf("macOS ic10 frame bounds = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := alphaBounds(img), image.Rect(100, 100, 924, 924); got != want {
|
||||
t.Fatalf("macOS icon visible bounds = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func alphaBounds(img image.Image) image.Rectangle {
|
||||
bounds := img.Bounds()
|
||||
visible := image.Rectangle{}
|
||||
found := false
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
_, _, _, a := img.At(x, y).RGBA()
|
||||
if a == 0 {
|
||||
continue
|
||||
}
|
||||
if !found {
|
||||
visible = image.Rect(x, y, x+1, y+1)
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
if x < visible.Min.X {
|
||||
visible.Min.X = x
|
||||
}
|
||||
if y < visible.Min.Y {
|
||||
visible.Min.Y = y
|
||||
}
|
||||
if x >= visible.Max.X {
|
||||
visible.Max.X = x + 1
|
||||
}
|
||||
if y >= visible.Max.Y {
|
||||
visible.Max.Y = y + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
func decodeICNSImage(t *testing.T, path, iconType string) image.Image {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) < 8 || string(data[:4]) != "icns" {
|
||||
t.Fatal("invalid ICNS header")
|
||||
}
|
||||
if declaredSize := int(binary.BigEndian.Uint32(data[4:8])); declaredSize != len(data) {
|
||||
t.Fatalf("ICNS size = %d, want %d", declaredSize, len(data))
|
||||
}
|
||||
|
||||
for offset := 8; offset < len(data); {
|
||||
if offset+8 > len(data) {
|
||||
t.Fatalf("truncated ICNS entry header at offset %d", offset)
|
||||
}
|
||||
entryType := string(data[offset : offset+4])
|
||||
entrySize := int(binary.BigEndian.Uint32(data[offset+4 : offset+8]))
|
||||
if entrySize < 8 || offset+entrySize > len(data) {
|
||||
t.Fatalf("invalid ICNS entry %q size %d at offset %d", entryType, entrySize, offset)
|
||||
}
|
||||
if entryType == iconType {
|
||||
img, err := png.Decode(bytes.NewReader(data[offset+8 : offset+entrySize]))
|
||||
if err != nil {
|
||||
t.Fatalf("decode ICNS entry %q: %v", iconType, err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
offset += entrySize
|
||||
}
|
||||
|
||||
t.Fatalf("ICNS is missing %q image", iconType)
|
||||
return nil
|
||||
}
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 52 KiB |
@@ -1,93 +0,0 @@
|
||||
# nfpm config for Reasonix Studio's Linux .deb. scripts/studio-build.sh runs
|
||||
# `nfpm package` from the repository root after the shell and frontend are
|
||||
# built, so every src path below is relative to that root.
|
||||
#
|
||||
# Studio installs under /usr/lib/reasonix-studio and answers to its own dpkg
|
||||
# package and Polkit action. The desktop package owns /usr/lib/reasonix and
|
||||
# io.reasonix.desktop.update, so the two can be installed side by side without
|
||||
# dpkg seeing either overwrite the other's files.
|
||||
#
|
||||
# $DEB_VERSION and $DEB_ARCH are exported by studio-build.sh. DEB_VERSION follows
|
||||
# Debian ordering: stable X.Y.Z, prereleases as X.Y.Z~preview.N so they sort
|
||||
# below the matching stable package.
|
||||
name: "reasonix-studio"
|
||||
arch: "${DEB_ARCH}"
|
||||
platform: "linux"
|
||||
version: "${DEB_VERSION}"
|
||||
section: "utils"
|
||||
priority: "optional"
|
||||
maintainer: "esengine <[email protected]>"
|
||||
description: |
|
||||
Reasonix Studio — the parallel preview shell around the Go kernel.
|
||||
vendor: "Reasonix Contributors"
|
||||
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
|
||||
license: "MIT"
|
||||
# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41).
|
||||
# pkexec carries the in-app update through Polkit.
|
||||
depends:
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
- pkexec
|
||||
contents:
|
||||
# os.Executable resolves /usr/bin/reasonix-studio through the symlink, so
|
||||
# frontendAssets() finds the SPA as a sibling of the real binary here.
|
||||
- src: ./dist/deb-payload/reasonix-studio
|
||||
dst: /usr/lib/reasonix-studio/reasonix-studio
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./dist/deb-payload/frontend-next/dist
|
||||
dst: /usr/lib/reasonix-studio/frontend-next/dist
|
||||
type: tree
|
||||
- src: ./dist/deb-payload/reasonix-studio-update-helper
|
||||
dst: /usr/lib/reasonix-studio/reasonix-studio-update-helper
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: /usr/lib/reasonix-studio/reasonix-studio
|
||||
dst: /usr/bin/reasonix-studio
|
||||
type: symlink
|
||||
- src: ./desktop/packaging/linux/io.reasonix.studio.update.policy
|
||||
dst: /usr/share/polkit-1/actions/io.reasonix.studio.update.policy
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./desktop/next/build/linux/reasonix-studio.desktop
|
||||
dst: /usr/share/applications/reasonix-studio.desktop
|
||||
file_info:
|
||||
mode: 0644
|
||||
# The 1024x1024 master is not a standard hicolor size, so it goes to the
|
||||
# size-agnostic pixmaps path the .desktop's Icon=reasonix-studio also finds.
|
||||
- src: ./desktop/next/build/linux/icons/reasonix-studio.png
|
||||
dst: /usr/share/pixmaps/reasonix-studio.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./desktop/next/build/linux/icons/reasonix-studio-16.png
|
||||
dst: /usr/share/icons/hicolor/16x16/apps/reasonix-studio.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./desktop/next/build/linux/icons/reasonix-studio-24.png
|
||||
dst: /usr/share/icons/hicolor/24x24/apps/reasonix-studio.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./desktop/next/build/linux/icons/reasonix-studio-32.png
|
||||
dst: /usr/share/icons/hicolor/32x32/apps/reasonix-studio.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./desktop/next/build/linux/icons/reasonix-studio-48.png
|
||||
dst: /usr/share/icons/hicolor/48x48/apps/reasonix-studio.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./desktop/next/build/linux/icons/reasonix-studio-64.png
|
||||
dst: /usr/share/icons/hicolor/64x64/apps/reasonix-studio.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./desktop/next/build/linux/icons/reasonix-studio-128.png
|
||||
dst: /usr/share/icons/hicolor/128x128/apps/reasonix-studio.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./desktop/next/build/linux/icons/reasonix-studio-256.png
|
||||
dst: /usr/share/icons/hicolor/256x256/apps/reasonix-studio.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./desktop/next/build/linux/icons/reasonix-studio-512.png
|
||||
dst: /usr/share/icons/hicolor/512x512/apps/reasonix-studio.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
@@ -1,9 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Reasonix Studio
|
||||
Comment=Reasonix Studio — the parallel preview shell around the Go kernel
|
||||
Exec=reasonix-studio
|
||||
Icon=reasonix-studio
|
||||
Categories=Development;Utility;
|
||||
Terminal=false
|
||||
StartupWMClass=reasonix-studio
|
||||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 41 KiB |
@@ -1,179 +0,0 @@
|
||||
; Reasonix Studio's Windows installer.
|
||||
;
|
||||
; Studio installs one executable beside the SPA it draws its UI from, so this is
|
||||
; a plain per-machine install rather than the desktop line's versioned layout —
|
||||
; that one exists to swap a running install under a launcher, which Studio does
|
||||
; not have yet. Windows artifacts from this line are unsigned: the production
|
||||
; SignPath policy allows exactly one origin (protected main-v2), so a build from
|
||||
; the studio branch cannot request it and SmartScreen will warn.
|
||||
;
|
||||
; Invoked by scripts/studio-build.sh:
|
||||
; makensis -DVERSION=2.0.0 -DPAYLOAD=<dir> -DOUTFILE=<path> studio.nsi
|
||||
|
||||
Unicode true
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "MUI2.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
!include "x64.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
!insertmacro GetSize
|
||||
|
||||
!ifndef VERSION
|
||||
!error "VERSION is required"
|
||||
!endif
|
||||
!ifndef PAYLOAD
|
||||
!error "PAYLOAD is required"
|
||||
!endif
|
||||
!ifndef OUTFILE
|
||||
!error "OUTFILE is required"
|
||||
!endif
|
||||
|
||||
!define APPNAME "Reasonix Studio"
|
||||
!define BINNAME "reasonix-studio.exe"
|
||||
!define PUBLISHER "Reasonix Contributors"
|
||||
!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\ReasonixStudio"
|
||||
|
||||
Name "${APPNAME}"
|
||||
OutFile "${OUTFILE}"
|
||||
InstallDir "$PROGRAMFILES64\${APPNAME}"
|
||||
InstallDirRegKey HKLM "Software\ReasonixStudio" "InstallDir"
|
||||
RequestExecutionLevel admin
|
||||
SetCompressor /SOLID lzma
|
||||
|
||||
VIProductVersion "${VERSION}.0"
|
||||
VIAddVersionKey "ProductName" "${APPNAME}"
|
||||
VIAddVersionKey "FileDescription" "${APPNAME} installer"
|
||||
VIAddVersionKey "FileVersion" "${VERSION}"
|
||||
VIAddVersionKey "ProductVersion" "${VERSION}"
|
||||
VIAddVersionKey "CompanyName" "${PUBLISHER}"
|
||||
VIAddVersionKey "LegalCopyright" "Copyright © 2026 ${PUBLISHER}"
|
||||
|
||||
!define MUI_ABORTWARNING
|
||||
!define MUI_ICON "${PAYLOAD}\appicon.ico"
|
||||
!define MUI_UNICON "${PAYLOAD}\appicon.ico"
|
||||
|
||||
; The finish page must not start Studio itself. This installer runs elevated, a
|
||||
; process it launches inherits that token, and on an account that elevated by
|
||||
; entering someone else's credentials %APPDATA% then resolves to the
|
||||
; administrator's profile — so the first launch after an update reads a config
|
||||
; file that knows nothing about the user's own settings, and reports their
|
||||
; relocated storage as gone. explorer.exe is already running as the signed-in
|
||||
; user, so handing it the path drops back to that token; Exec straight to the
|
||||
; binary is the fallback for a session whose shell is not explorer, where the
|
||||
; wrong token still beats no launch at all.
|
||||
Function LaunchAsSignedInUser
|
||||
ClearErrors
|
||||
Exec '"$WINDIR\explorer.exe" "$INSTDIR\${BINNAME}"'
|
||||
IfErrors 0 +2
|
||||
Exec '"$INSTDIR\${BINNAME}"'
|
||||
FunctionEnd
|
||||
|
||||
!define MUI_FINISHPAGE_RUN "$INSTDIR\${BINNAME}"
|
||||
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchAsSignedInUser
|
||||
!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APPNAME}"
|
||||
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
!insertmacro MUI_LANGUAGE "SimpChinese"
|
||||
!insertmacro MUI_LANGUAGE "TradChinese"
|
||||
|
||||
; WebView2 is what Wails renders into. A machine without it shows an empty
|
||||
; window rather than an error, so the bootstrapper runs before the first launch
|
||||
; and is skipped when any runtime is already registered. WEBVIEW2 is defined
|
||||
; only when the build could fetch the bootstrapper; without it the installer
|
||||
; still installs, and Windows 11 already ships the runtime.
|
||||
Function EnsureWebView2
|
||||
!ifdef WEBVIEW2
|
||||
ReadRegStr $0 HKLM \
|
||||
"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Return
|
||||
${EndIf}
|
||||
ReadRegStr $0 HKCU \
|
||||
"Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Return
|
||||
${EndIf}
|
||||
DetailPrint "Installing the WebView2 runtime…"
|
||||
InitPluginsDir
|
||||
File /oname=$PLUGINSDIR\MicrosoftEdgeWebview2Setup.exe "${PAYLOAD}\MicrosoftEdgeWebview2Setup.exe"
|
||||
ExecWait '"$PLUGINSDIR\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||
!endif
|
||||
FunctionEnd
|
||||
|
||||
; An in-app update starts this installer and then exits, so for a moment the old
|
||||
; Studio still holds its executable open and Windows refuses to replace it.
|
||||
; Waiting for the file to become writable is what turns that race into a pause;
|
||||
; giving up after the timeout lets the File command report the real error rather
|
||||
; than looping forever behind an app the user never closed.
|
||||
Function WaitForRunningStudio
|
||||
StrCpy $R1 0
|
||||
wait:
|
||||
IfFileExists "$INSTDIR\${BINNAME}" 0 done
|
||||
ClearErrors
|
||||
FileOpen $R2 "$INSTDIR\${BINNAME}" a
|
||||
IfErrors busy
|
||||
FileClose $R2
|
||||
Goto done
|
||||
busy:
|
||||
IntOp $R1 $R1 + 1
|
||||
IntCmp $R1 40 done done 0
|
||||
DetailPrint "Waiting for ${APPNAME} to close…"
|
||||
Sleep 500
|
||||
Goto wait
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Section "install"
|
||||
SetOutPath "$INSTDIR"
|
||||
Call WaitForRunningStudio
|
||||
File "${PAYLOAD}\${BINNAME}"
|
||||
File "${PAYLOAD}\appicon.ico"
|
||||
; frontendAssets() resolves the SPA next to the executable, so the tree has to
|
||||
; land as a sibling and not be flattened into $INSTDIR.
|
||||
SetOutPath "$INSTDIR\frontend-next\dist"
|
||||
File /r "${PAYLOAD}\frontend-next\dist\*"
|
||||
|
||||
SetOutPath "$INSTDIR"
|
||||
Call EnsureWebView2
|
||||
|
||||
CreateDirectory "$SMPROGRAMS\${APPNAME}"
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" "$INSTDIR\${BINNAME}" "" "$INSTDIR\appicon.ico"
|
||||
|
||||
WriteRegStr HKLM "Software\ReasonixStudio" "InstallDir" "$INSTDIR"
|
||||
WriteRegStr HKLM "${UNINSTKEY}" "DisplayName" "${APPNAME}"
|
||||
WriteRegStr HKLM "${UNINSTKEY}" "DisplayVersion" "${VERSION}"
|
||||
WriteRegStr HKLM "${UNINSTKEY}" "Publisher" "${PUBLISHER}"
|
||||
WriteRegStr HKLM "${UNINSTKEY}" "DisplayIcon" "$INSTDIR\appicon.ico"
|
||||
WriteRegStr HKLM "${UNINSTKEY}" "InstallLocation" "$INSTDIR"
|
||||
WriteRegStr HKLM "${UNINSTKEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||
WriteRegDWORD HKLM "${UNINSTKEY}" "NoModify" 1
|
||||
WriteRegDWORD HKLM "${UNINSTKEY}" "NoRepair" 1
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKLM "${UNINSTKEY}" "EstimatedSize" "$0"
|
||||
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
SectionEnd
|
||||
|
||||
Section "uninstall"
|
||||
Delete "$INSTDIR\${BINNAME}"
|
||||
Delete "$INSTDIR\appicon.ico"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
RMDir /r "$INSTDIR\frontend-next"
|
||||
; Only the directory this installer created; /r on $INSTDIR would take a
|
||||
; user's files with it if they ever installed into a shared folder.
|
||||
RMDir "$INSTDIR"
|
||||
|
||||
Delete "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk"
|
||||
RMDir "$SMPROGRAMS\${APPNAME}"
|
||||
|
||||
DeleteRegKey HKLM "${UNINSTKEY}"
|
||||
DeleteRegKey HKLM "Software\ReasonixStudio"
|
||||
SectionEnd
|
||||
@@ -1,19 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/history"
|
||||
)
|
||||
|
||||
// The history projection is process-wide, shared by every pane, so it is closed
|
||||
// at the process boundary and not with any one pane's controller. Draining
|
||||
// queued index writes first is what saves the next launch a full re-scan of
|
||||
// every root.
|
||||
func closeProcessCatalogs() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = history.FlushSharedCatalog(ctx)
|
||||
_ = history.CloseSharedCatalog(ctx)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// closing.go — the window stays up until its session is safely on disk.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// A window that vanishes while its process is still saving has told the user the
|
||||
// app is closed. Reopening then puts a second process on a session the first is
|
||||
// still writing, and two writers on one transcript is what forks it into an
|
||||
// endless run of recovery copies. So the close button starts the shutdown and
|
||||
// the window stays up for it — nothing is faster, but nothing lies either.
|
||||
type closePhase = int32
|
||||
|
||||
// A phase, not a pair of flags: "saving" and "done" are one question asked
|
||||
// once, and two booleans would admit a done-but-not-saving state that means
|
||||
// nothing here.
|
||||
const (
|
||||
closeIdle closePhase = iota
|
||||
closeSaving
|
||||
closeDone
|
||||
)
|
||||
|
||||
// closeState carries the phase. A type so the field reads as what it is at the
|
||||
// declaration, rather than as an integer whose meaning lives elsewhere.
|
||||
type closeState struct{ atomic.Int32 }
|
||||
|
||||
// quitWindow is runtime.Quit behind a variable, so the close sequence can be
|
||||
// exercised without a Wails application behind the context. hideWindow is the
|
||||
// same treatment for the branch that keeps the process alive.
|
||||
var (
|
||||
quitWindow = runtime.Quit
|
||||
hideWindow = runtime.WindowHide
|
||||
)
|
||||
|
||||
// beginClose answers Wails' "may the window close?" — false lets it go.
|
||||
func (a *App) beginClose(ctx context.Context) (prevent bool) {
|
||||
// Where an icon can bring it back, the close button hides. Nothing else
|
||||
// changes: the session stays open because it is still this process's, and
|
||||
// the icon is what keeps that visible rather than a ghost.
|
||||
if a.background.Load() && a.closing.Load() == closeIdle {
|
||||
hideWindow(ctx)
|
||||
return true
|
||||
}
|
||||
if !a.closing.CompareAndSwap(closeIdle, closeSaving) {
|
||||
// Clicking again while it saves makes nothing faster, and must not take
|
||||
// the window down early either: only our own quit does that.
|
||||
return a.closing.Load() != closeDone
|
||||
}
|
||||
go a.finishClose(ctx)
|
||||
return true
|
||||
}
|
||||
|
||||
// finishClose lands every pane and then asks the window to go, which comes back
|
||||
// through beginClose with the phase that lets it.
|
||||
func (a *App) finishClose(ctx context.Context) {
|
||||
a.hub.Shutdown()
|
||||
a.closing.Store(closeDone)
|
||||
quitWindow(ctx)
|
||||
}
|
||||
|
||||
// showWindow brings the window back from wherever it went.
|
||||
func (a *App) showWindow() {
|
||||
if ctx := a.context(); ctx != nil {
|
||||
restoreWindow(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// restoreWindow is the platform half behind a variable, the way quitWindow is.
|
||||
// Show and unminimise are both no-ops on a window that is merely buried behind
|
||||
// other apps — the common case, and the one that read as a dead icon — so the
|
||||
// always-on-top flip is what actually raises it. Wails v2 offers no other ask.
|
||||
var restoreWindow = func(ctx context.Context) {
|
||||
runtime.WindowUnminimise(ctx)
|
||||
runtime.WindowShow(ctx)
|
||||
runtime.WindowSetAlwaysOnTop(ctx, true)
|
||||
runtime.WindowSetAlwaysOnTop(ctx, false)
|
||||
}
|
||||
|
||||
// quitFromTray is the close the window's own button takes when nothing is
|
||||
// backgrounded: land every pane, then let the window go. It is the same
|
||||
// sequence rather than a second one, because the reason that sequence exists —
|
||||
// two processes writing one transcript — does not care which control asked.
|
||||
func (a *App) quitFromTray() {
|
||||
ctx := a.context()
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
if !a.closing.CompareAndSwap(closeIdle, closeSaving) {
|
||||
return
|
||||
}
|
||||
go a.finishClose(ctx)
|
||||
}
|
||||
|
||||
func (a *App) context() context.Context {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.ctx
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/serve"
|
||||
)
|
||||
|
||||
// The close button must not take the window down while the session is still
|
||||
// being written: a window that is gone reads as an app that has closed, and
|
||||
// reopening on top of a process that is still saving is what puts two writers on
|
||||
// one transcript.
|
||||
func TestTheWindowOutlivesItsCloseButton(t *testing.T) {
|
||||
quit := make(chan struct{}, 1)
|
||||
restore := quitWindow
|
||||
quitWindow = func(context.Context) { quit <- struct{}{} }
|
||||
t.Cleanup(func() { quitWindow = restore })
|
||||
|
||||
a := &App{hub: serve.NewHub(serve.HubOptions{})}
|
||||
ctx := context.Background()
|
||||
|
||||
if !a.beginClose(ctx) {
|
||||
t.Fatal("the first click let the window go before anything was saved")
|
||||
}
|
||||
select {
|
||||
case <-quit:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the shutdown never asked the window to quit")
|
||||
}
|
||||
// The quit it just asked for arrives as another close: this one may pass.
|
||||
if a.beginClose(ctx) {
|
||||
t.Fatal("the window refused the quit its own shutdown asked for")
|
||||
}
|
||||
}
|
||||
|
||||
// Clicking again while it saves is not a way to skip the saving.
|
||||
func TestClickingCloseAgainDoesNotCutTheSaveShort(t *testing.T) {
|
||||
a := &App{hub: serve.NewHub(serve.HubOptions{})}
|
||||
a.closing.Store(closeSaving)
|
||||
for range 3 {
|
||||
if !a.beginClose(context.Background()) {
|
||||
t.Fatal("a click while the session was still being written took the window down")
|
||||
}
|
||||
}
|
||||
if a.closing.Load() != closeSaving {
|
||||
t.Error("clicking again moved the shutdown along; only the shutdown itself may")
|
||||
}
|
||||
}
|
||||
|
||||
// Backgrounding is what a status icon buys: the window goes away, the session
|
||||
// does not. Saving here would be the opposite of the point — nothing is being
|
||||
// closed, so nothing has to be landed.
|
||||
func TestBackgroundingHidesTheWindowWithoutClosingTheSession(t *testing.T) {
|
||||
hidden := make(chan struct{}, 1)
|
||||
restoreHide, restoreQuit := hideWindow, quitWindow
|
||||
hideWindow = func(context.Context) { hidden <- struct{}{} }
|
||||
quitWindow = func(context.Context) { t.Error("backgrounding asked the window to quit") }
|
||||
t.Cleanup(func() { hideWindow, quitWindow = restoreHide, restoreQuit })
|
||||
|
||||
a := &App{hub: serve.NewHub(serve.HubOptions{})}
|
||||
a.background.Store(true)
|
||||
if !a.beginClose(context.Background()) {
|
||||
t.Fatal("the window closed instead of stepping aside")
|
||||
}
|
||||
select {
|
||||
case <-hidden:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the window neither closed nor hid")
|
||||
}
|
||||
if a.closing.Load() != closeIdle {
|
||||
t.Fatal("hiding started a shutdown; nothing is being closed")
|
||||
}
|
||||
}
|
||||
|
||||
// Quitting from the icon is the same sequence the close button runs when
|
||||
// nothing is backgrounded — the reason that sequence exists does not care which
|
||||
// control asked for it.
|
||||
func TestQuittingFromTheIconStillLandsTheSession(t *testing.T) {
|
||||
quit := make(chan struct{}, 1)
|
||||
restoreHide, restoreQuit := hideWindow, quitWindow
|
||||
hideWindow = func(context.Context) { t.Error("the quit hid the window instead") }
|
||||
quitWindow = func(context.Context) { quit <- struct{}{} }
|
||||
t.Cleanup(func() { hideWindow, quitWindow = restoreHide, restoreQuit })
|
||||
|
||||
a := &App{hub: serve.NewHub(serve.HubOptions{})}
|
||||
a.background.Store(true)
|
||||
a.mu.Lock()
|
||||
a.ctx = context.Background()
|
||||
a.mu.Unlock()
|
||||
|
||||
a.quitFromTray()
|
||||
select {
|
||||
case <-quit:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the quit never landed the session")
|
||||
}
|
||||
if a.closing.Load() != closeDone {
|
||||
t.Fatalf("phase = %d, want the shutdown finished", a.closing.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// The icon read as dead because the window was already open, just buried:
|
||||
// show and unminimise both do nothing to a window in that state, so the click
|
||||
// has to ask for the raise as well.
|
||||
func TestClickingTheIconRaisesAWindowThatIsMerelyBuried(t *testing.T) {
|
||||
raised := make(chan struct{}, 1)
|
||||
restore := restoreWindow
|
||||
restoreWindow = func(context.Context) { raised <- struct{}{} }
|
||||
t.Cleanup(func() { restoreWindow = restore })
|
||||
|
||||
a := &App{hub: serve.NewHub(serve.HubOptions{})}
|
||||
a.mu.Lock()
|
||||
a.ctx = context.Background()
|
||||
a.mu.Unlock()
|
||||
|
||||
a.showWindow()
|
||||
select {
|
||||
case <-raised:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the click showed the window without raising it, which is what it looked like")
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where a Studio crash goes to not be lost. Every one reported so far has been
|
||||
// a fatal signal inside GTK or WebKit, and the runtime writes that trace to the
|
||||
// descriptor — not through a writer this process could tee. Launched from its
|
||||
// .desktop entry nothing holds the other end, which is why the only trace ever
|
||||
// attached to an issue came from someone who started Studio from a shell.
|
||||
const (
|
||||
studioLogPrefix = "studio-"
|
||||
studioLogsKept = 5
|
||||
)
|
||||
|
||||
// openCrashLog points this process's stderr at a file under <home>/logs and
|
||||
// returns where the window's own logs should go: that file, plus the terminal
|
||||
// when there was one, so running Studio from a shell still prints.
|
||||
func openCrashLog(home string) (io.Writer, func(), error) {
|
||||
quiet := func() {}
|
||||
if strings.TrimSpace(home) == "" {
|
||||
return os.Stderr, quiet, nil
|
||||
}
|
||||
dir := filepath.Join(home, "logs")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return os.Stderr, quiet, err
|
||||
}
|
||||
name := fmt.Sprintf("%s%s-%d.log", studioLogPrefix, time.Now().UTC().Format("20060102-150405"), os.Getpid())
|
||||
file, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return os.Stderr, quiet, err
|
||||
}
|
||||
pruneStudioLogs(dir)
|
||||
flush := func() { _ = file.Sync(); _ = file.Close() }
|
||||
|
||||
if !canRedirectStderr {
|
||||
// The window's own logs still reach the file; a fatal signal's trace
|
||||
// does not. Windows resolves its stderr handle once at start, so moving
|
||||
// os.Stderr afterwards would not move the write that matters.
|
||||
return io.MultiWriter(os.Stderr, file), flush, nil
|
||||
}
|
||||
// Taken before the redirect: afterwards fd 2 is the file, and there is no
|
||||
// way back to what it used to be.
|
||||
tty := terminalStderr()
|
||||
if err := redirectStderr(file); err != nil {
|
||||
return io.MultiWriter(os.Stderr, file), flush, err
|
||||
}
|
||||
if tty == nil {
|
||||
return os.Stderr, flush, nil
|
||||
}
|
||||
return io.MultiWriter(os.Stderr, tty), flush, nil
|
||||
}
|
||||
|
||||
// terminalStderr duplicates stderr while it still is one, and answers nil when
|
||||
// this process was launched without a terminal — from a .desktop entry, which
|
||||
// is how the people who hit these crashes start it.
|
||||
func terminalStderr() *os.File {
|
||||
info, err := os.Stderr.Stat()
|
||||
if err != nil || info.Mode()&os.ModeCharDevice == 0 {
|
||||
return nil
|
||||
}
|
||||
dup, err := dupStderr()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return dup
|
||||
}
|
||||
|
||||
// pruneStudioLogs keeps the newest few. One log per launch is what makes the
|
||||
// run before this one still readable — a crash is reported after a relaunch,
|
||||
// never during the run that produced it.
|
||||
func pruneStudioLogs(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var logs []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !entry.IsDir() && strings.HasPrefix(name, studioLogPrefix) && strings.HasSuffix(name, ".log") {
|
||||
logs = append(logs, name)
|
||||
}
|
||||
}
|
||||
// The name carries the stamp, so sorting the name sorts by age.
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(logs)))
|
||||
for _, name := range logs[min(len(logs), studioLogsKept):] {
|
||||
_ = os.Remove(filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const canRedirectStderr = true
|
||||
|
||||
func redirectStderr(f *os.File) error {
|
||||
return unix.Dup2(int(f.Fd()), int(os.Stderr.Fd()))
|
||||
}
|
||||
|
||||
func dupStderr() (*os.File, error) {
|
||||
fd, err := unix.Dup(int(os.Stderr.Fd()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.NewFile(uintptr(fd), "stderr"), nil
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Dup3 rather than Dup2: linux/arm64 has no dup2 syscall at all, so the pair
|
||||
// that exists everywhere Linux runs is this one.
|
||||
const canRedirectStderr = true
|
||||
|
||||
func redirectStderr(f *os.File) error {
|
||||
return unix.Dup3(int(f.Fd()), int(os.Stderr.Fd()), 0)
|
||||
}
|
||||
|
||||
func dupStderr() (*os.File, error) {
|
||||
fd, err := unix.Dup(int(os.Stderr.Fd()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.NewFile(uintptr(fd), "stderr"), nil
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//go:build !linux && !darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Windows resolves the handle its crash writer uses once, at start, so pointing
|
||||
// os.Stderr somewhere else afterwards moves the window's own logs and not the
|
||||
// trace. Saying so is better than a redirect that looks like it worked.
|
||||
const canRedirectStderr = false
|
||||
|
||||
var errNoStderrRedirect = errors.New("stderr redirect is not available on this platform")
|
||||
|
||||
func redirectStderr(*os.File) error { return errNoStderrRedirect }
|
||||
|
||||
func dupStderr() (*os.File, error) { return nil, errNoStderrRedirect }
|
||||
@@ -1,151 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The point of the redirect is the trace the Go runtime writes straight to the
|
||||
// descriptor when a fatal signal lands — every Studio crash reported so far has
|
||||
// been one, inside GTK or WebKit, where no recover can reach. A tee on a writer
|
||||
// would keep every log line and lose exactly that, so what this asserts is the
|
||||
// descriptor and not the writer.
|
||||
func TestCrashLogCatchesWhatIsWrittenToTheDescriptor(t *testing.T) {
|
||||
if !canRedirectStderr {
|
||||
t.Skip("this platform keeps the handle its crash writer resolved at start")
|
||||
}
|
||||
saved, err := dupStderr()
|
||||
if err != nil {
|
||||
t.Fatalf("dup stderr: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = redirectStderr(saved)
|
||||
_ = saved.Close()
|
||||
})
|
||||
|
||||
home := t.TempDir()
|
||||
logs, flush, err := openCrashLog(home)
|
||||
if err != nil {
|
||||
t.Fatalf("openCrashLog: %v", err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "FATAL-TRACE-STANDIN")
|
||||
fmt.Fprintln(logs, "WINDOW-LOG-LINE")
|
||||
flush()
|
||||
|
||||
body := theOnlyStudioLog(t, home)
|
||||
for _, want := range []string{"FATAL-TRACE-STANDIN", "WINDOW-LOG-LINE"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("the log is missing %q; it holds:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half, and the only half a platform without the redirect gets: what
|
||||
// the window itself logs has to reach the file on every platform, or a report
|
||||
// arrives with the crash and nothing that led up to it.
|
||||
func TestCrashLogKeepsWhatTheWindowLogs(t *testing.T) {
|
||||
saved := (*os.File)(nil)
|
||||
if canRedirectStderr {
|
||||
var err error
|
||||
if saved, err = dupStderr(); err != nil {
|
||||
t.Fatalf("dup stderr: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = redirectStderr(saved)
|
||||
_ = saved.Close()
|
||||
})
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
logs, flush, err := openCrashLog(home)
|
||||
if err != nil {
|
||||
t.Fatalf("openCrashLog: %v", err)
|
||||
}
|
||||
fmt.Fprintln(logs, "WINDOW-LOG-LINE")
|
||||
flush()
|
||||
|
||||
if body := theOnlyStudioLog(t, home); !strings.Contains(body, "WINDOW-LOG-LINE") {
|
||||
t.Fatalf("the log is missing the window's own line; it holds:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A home Studio cannot write to is not a reason not to start: the window is
|
||||
// what the person came for, and the log is what would have helped later.
|
||||
func TestCrashLogWithoutAHomeStillAnswersAWriter(t *testing.T) {
|
||||
logs, flush, err := openCrashLog("")
|
||||
defer flush()
|
||||
if err != nil {
|
||||
t.Fatalf("openCrashLog: %v", err)
|
||||
}
|
||||
if logs == nil {
|
||||
t.Fatal("a window with nowhere to log still has to have somewhere to write")
|
||||
}
|
||||
}
|
||||
|
||||
func theOnlyStudioLog(t *testing.T, home string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(home, "logs")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("want one log, got %v", names)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// A crash is reported after a relaunch, never during the run that produced it,
|
||||
// so the log worth keeping is never the newest one.
|
||||
func TestPruneStudioLogsKeepsTheNewestFew(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var made []string
|
||||
for i := range studioLogsKept + 3 {
|
||||
name := fmt.Sprintf("%s2026010%d-000000-%d.log", studioLogPrefix, i, i)
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
made = append(made, name)
|
||||
}
|
||||
// The directory is shared with the CLI's own diagnostics, so anything this
|
||||
// file does not own has to survive.
|
||||
const other = "cli-tui-20260101-000000.log"
|
||||
if err := os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pruneStudioLogs(dir)
|
||||
|
||||
left := map[string]bool{}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
left[entry.Name()] = true
|
||||
}
|
||||
if !left[other] {
|
||||
t.Fatal("pruning took a log this file does not own")
|
||||
}
|
||||
for _, name := range made[:3] {
|
||||
if left[name] {
|
||||
t.Fatalf("%s should have been pruned; left = %v", name, left)
|
||||
}
|
||||
}
|
||||
for _, name := range made[3:] {
|
||||
if !left[name] {
|
||||
t.Fatalf("%s should have been kept; left = %v", name, left)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
void setReasonixDockIcon(const void *data, int length);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// macOS reads the Dock icon out of the app bundle, and the shell also runs as a
|
||||
// bare `go build ./next` binary with no bundle around it. Carrying the artwork
|
||||
// is the counterpart to the committed .syso that brands the Windows build.
|
||||
//
|
||||
//go:embed appicon.icns
|
||||
var dockIcon []byte
|
||||
|
||||
func applyDockIcon() {
|
||||
if len(dockIcon) == 0 {
|
||||
return
|
||||
}
|
||||
C.setReasonixDockIcon(unsafe.Pointer(&dockIcon[0]), C.int(len(dockIcon)))
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
// Hop to the main queue: AppKit reads and redraws the Dock tile there, and the
|
||||
// caller is a Wails lifecycle hook that may not be it.
|
||||
void setReasonixDockIcon(const void *data, int length) {
|
||||
if (data == NULL || length <= 0) {
|
||||
return;
|
||||
}
|
||||
NSData *bytes = [NSData dataWithBytes:data length:(NSUInteger)length];
|
||||
NSImage *icon = [[NSImage alloc] initWithData:bytes];
|
||||
if (icon == nil) {
|
||||
return;
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSApplication.sharedApplication.applicationIconImage = icon;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build !darwin || !cgo
|
||||
|
||||
package main
|
||||
|
||||
func applyDockIcon() {}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
Hardened runtime entitlements for Studio. Notarization requires the hardened
|
||||
runtime, and under it WebKit's JavaScript JIT is refused unless it is granted
|
||||
here — the window would render nothing but a blank page. No network or file
|
||||
entitlement: this build is not sandboxed, so it needs none to reach what the
|
||||
user already gave it.
|
||||
|
||||
Library validation is disabled to match the desktop line, whose set is the
|
||||
only one in this repository a signed Wails build has ever launched under. It
|
||||
is the one entitlement whose absence neither codesign nor notarization
|
||||
reports — it surfaces on a real Mac at launch, and this line had never
|
||||
produced a signed build to find out on.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,12 +0,0 @@
|
||||
// filedrop.go — dropped files, as paths rather than bytes.
|
||||
package main
|
||||
|
||||
import "github.com/wailsapp/wails/v2/pkg/options"
|
||||
|
||||
// dragAndDrop asks the native side to report dropped files' absolute paths: a
|
||||
// webview hands JavaScript a File and never a path, and the path is what lets a
|
||||
// turn work on the file itself rather than on a copy. Nothing subscribes here —
|
||||
// only the page can say which element a drop landed on.
|
||||
func dragAndDrop() *options.DragAndDrop {
|
||||
return &options.DragAndDrop{EnableFileDrop: true}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Asking for dropped paths must not cost the window its ordinary drop: the
|
||||
// composer still reads the DOM event for the bytes a browser tab has instead of
|
||||
// a path. DisableWebViewDrop is the one switch that silences it — on macOS by
|
||||
// short-circuiting performDragOperation, on Linux by gtk_drag_dest_unset — and
|
||||
// nothing here would notice until a drop stopped landing.
|
||||
func TestFileDropKeepsTheWebviewsOwnDrop(t *testing.T) {
|
||||
dnd := dragAndDrop()
|
||||
if dnd == nil || !dnd.EnableFileDrop {
|
||||
t.Fatal("file drop is off; a turn cannot reference a dropped file without it")
|
||||
}
|
||||
if dnd.DisableWebViewDrop {
|
||||
t.Fatal("DisableWebViewDrop would take the dropped bytes away from the composer")
|
||||
}
|
||||
}
|
||||
|
||||
// The paths are taken through the Wails runtime in the page, not relayed from
|
||||
// here: a relay would report every drop in the window with nothing to say which
|
||||
// element it landed on, and that answer only exists in the DOM.
|
||||
func TestPathsAreNotRelayedFromTheShell(t *testing.T) {
|
||||
src, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(src), "OnFileDrop") {
|
||||
t.Fatal("the shell subscribes to file drops; that channel says nothing about the drop target, so the page must be the subscriber")
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime offers to filter drops itself, hit-testing native drop
|
||||
// coordinates against a CSS opt-in. The layout is in CSS pixels, so the two
|
||||
// part ways under an interface zoom and the filter rejects drops that landed
|
||||
// squarely on the composer — file drop broken on one machine and nowhere else.
|
||||
// The page routes against the DOM; asking again puts the arithmetic back.
|
||||
func TestTheRuntimeFilterIsNotAskedFor(t *testing.T) {
|
||||
src, err := os.ReadFile(filepath.Join("..", "frontend-next", "src", "port", "sse.ts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := strings.Index(string(src), "rt.OnFileDrop(")
|
||||
if call < 0 {
|
||||
t.Fatal("nothing subscribes to dropped paths, so a dropped file never reaches a turn")
|
||||
}
|
||||
stmt := string(src)[call:]
|
||||
end := strings.Index(stmt, ";")
|
||||
if end < 0 {
|
||||
t.Fatal("could not read the OnFileDrop call")
|
||||
}
|
||||
if !strings.HasSuffix(strings.TrimSuffix(strings.TrimSpace(stmt[:end]), ")"), "false") {
|
||||
t.Fatalf("OnFileDrop asks for the runtime's own drop-target filter; it hit-tests native coordinates against CSS pixels: %s", stmt[:end])
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/appupdate"
|
||||
"reasonix/internal/serve"
|
||||
)
|
||||
|
||||
// hubOptionKeys reads the options this shell actually builds its hub with, from
|
||||
// the syntax rather than by running it: the assembly needs a window, and the
|
||||
// failure being guarded is silent — an option left out costs nothing at compile
|
||||
// time and takes the capability behind it off the wire.
|
||||
func hubOptionKeys(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse main.go: %v", err)
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := lit.Type.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "HubOptions" {
|
||||
return true
|
||||
}
|
||||
for _, elt := range lit.Elts {
|
||||
if kv, ok := elt.(*ast.KeyValueExpr); ok {
|
||||
if key, ok := kv.Key.(*ast.Ident); ok {
|
||||
keys[key.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("no hub options found; this guard is watching nothing")
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// A capability this shell implements and does not hand to its hub is one the
|
||||
// kernel never registers a route for, so the panel that shows it goes blank and
|
||||
// the menu that drives it reaches a nil host. It happened once, to Tray, and
|
||||
// nothing failed until a person clicked.
|
||||
func TestTheWindowHandsItsHubEveryCapabilityItImplements(t *testing.T) {
|
||||
keys := hubOptionKeys(t)
|
||||
for _, want := range []string{"Tray", "Update", "Install", "Asks", "Remote", "OnOpen", "OnClose", "DecorateSink", "Grant"} {
|
||||
if !keys[want] {
|
||||
t.Errorf("the hub is built without %s, so nothing can reach what this window implements for it", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// appupdate and serve name this capability from two packages that may not
|
||||
// import each other, so the property lives in the assignment between them: an
|
||||
// unowned application must leave the field nil, and a nil *capability inside a
|
||||
// non-nil interface would register the routes and fail nowhere. Asserting it in
|
||||
// appupdate stops working the moment New returns a concrete type.
|
||||
func TestAnUnownedApplicationReachesTheHubAsNoUpdateHostAtAll(t *testing.T) {
|
||||
var opts serve.HubOptions
|
||||
opts.Update = appupdate.New(appupdate.Options{Running: "v1.0.0"})
|
||||
if opts.Update != nil {
|
||||
t.Fatalf("HubOptions.Update = %#v, want nil so the update routes stay unregistered", opts.Update)
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
goruntime "runtime"
|
||||
|
||||
"reasonix/internal/update"
|
||||
)
|
||||
|
||||
// The three acts of a handover, which this shell performs as one process
|
||||
// because it is its own application. The kernel decides when; these say how,
|
||||
// and they are separate because they are not one act everywhere.
|
||||
|
||||
// PrepareForUpdate releases what would keep this build's own files from being
|
||||
// replaced. The exit that has to follow belongs to EndApplication: what
|
||||
// releases the application and what ends it are not the same act.
|
||||
func (a *App) PrepareForUpdate(context.Context) error {
|
||||
if a.hub != nil {
|
||||
a.hub.Shutdown()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RelaunchAfterUpdate starts what replaced this build, where that is this
|
||||
// process's half of it. Windows and macOS already have a helper waiting for the
|
||||
// exit; Linux replaced the files in place, so nobody else is waiting.
|
||||
func (a *App) RelaunchAfterUpdate(context.Context) error {
|
||||
if goruntime.GOOS != "linux" {
|
||||
return nil
|
||||
}
|
||||
return update.Here(update.StudioLine()).Relaunch()
|
||||
}
|
||||
|
||||
// EndApplication ends this process, which for this shell is the application.
|
||||
func (a *App) EndApplication(context.Context) {
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
// Wails' darwin frontend references UTType. `wails build` links this framework
|
||||
// implicitly; a plain `go build` does not.
|
||||
#cgo LDFLAGS: -framework UniformTypeIdentifiers
|
||||
*/
|
||||
import "C"
|
||||
@@ -1,613 +0,0 @@
|
||||
// Command reasonix-studio is the Wails shell for frontend-next. It differs from
|
||||
// the existing desktop binary in one way that matters: the UI talks to the
|
||||
// kernel over internal/serve's HTTP surface instead of Wails bindings, so the
|
||||
// same build runs in a browser against `reasonix serve`.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reasonix/internal/i18n"
|
||||
goruntime "runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/menu"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"reasonix/internal/appupdate"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/crashreport"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/notify"
|
||||
"reasonix/internal/remotehost"
|
||||
"reasonix/internal/serve"
|
||||
"reasonix/internal/surface"
|
||||
"reasonix/internal/traystate"
|
||||
"reasonix/internal/update"
|
||||
|
||||
// Kinds register from init, so a binary builds only what it links. Without
|
||||
// these the shell answers every Anthropic model with "unknown kind" at
|
||||
// switch time; openai alone arrived, pulled in transitively by config.
|
||||
_ "reasonix/internal/provider/anthropic"
|
||||
_ "reasonix/internal/provider/openai"
|
||||
_ "reasonix/internal/provider/responses"
|
||||
)
|
||||
|
||||
// Everything the SPA is allowed to route to the kernel. Anything else falls
|
||||
// through to the static assets, so an unknown path renders index.html rather
|
||||
// than reaching the controller.
|
||||
// Must match the name and path SsePort uses when it detects the Wails runtime.
|
||||
const (
|
||||
wailsEventName = "rx:event"
|
||||
replayPath = "/rx-replay"
|
||||
// Must match runtimePrefix in internal/serve and the frontend's pane base.
|
||||
runtimePrefix = "/rt/"
|
||||
)
|
||||
|
||||
var apiPaths = map[string]bool{
|
||||
"/events": true, "/events/replay": true, "/history": true, "/context": true, "/status": true,
|
||||
"/sessions": true, "/checkpoints": true, "/branches": true, "/models": true,
|
||||
"/submit": true, "/cancel": true, "/approve": true, "/answer": true,
|
||||
"/plan": true, "/plan-decision": true, "/preset": true, "/model": true, "/effort": true,
|
||||
"/goal": true, "/resume": true, "/compact": true, "/compaction": true, "/new": true,
|
||||
"/rewind": true, "/fork": true, "/summarize": true, "/forget": true,
|
||||
"/tool-approval-mode": true, "/auto-approve-tools": true, "/bypass": true,
|
||||
"/provider-setup": true, "/delete-session": true, "/inbox": true, "/inbox/items": true,
|
||||
"/trajectory": true, "/slash": true, "/complete": true,
|
||||
"/execution-graph": true, "/adjudications": true,
|
||||
"/workspace": true, "/workspaces": true,
|
||||
"/mcp": true, "/skills": true, "/capability-scope": true, "/account": true, "/hooks": true,
|
||||
"/memory": true, "/network": true, "/shell": true, "/todos": true, "/providers": true,
|
||||
"/changes": true, "/attachments": true, "/drop": true, "/roles": true,
|
||||
"/themes": true, "/extensions": true, "/plugins": true, "/surfaces": true,
|
||||
"/welcome": true, "/appearance": true,
|
||||
"/permissions": true, "/sandbox": true, "/storage": true, "/usage": true, "/tray": true, "/asks": true, "/update": true,
|
||||
"/balance": true,
|
||||
"/config/problem": true, "/config/repair": true,
|
||||
}
|
||||
|
||||
// A sub-path belongs to the resource it hangs off: /mcp/reconnect is the same
|
||||
// surface as /mcp. Listing families rather than every leaf is what keeps a new
|
||||
// endpoint from silently answering with index.html instead of JSON — and
|
||||
// TestEveryPathTheFrontendCallsIsRouted is what keeps this list honest, because
|
||||
// the comment alone did not.
|
||||
var apiPrefixes = []string{"/asks/", "/tray/", "/update/", "/mcp/", "/skills/", "/inbox/", "/account/", "/hooks/", "/memory/", "/network/", "/providers/", "/rewind/", "/extensions/", "/themes/", "/plugins/", "/appearance/", "/storage/", "/changes/", "/context/", "/studio/"}
|
||||
|
||||
// splitRuntimePath separates a pane's address from the route it is asking for:
|
||||
// /rt/r2/status is runtime r2 asking for /status. An unprefixed path belongs to
|
||||
// no pane in particular and reaches the hub's first runtime.
|
||||
func splitRuntimePath(p string) (id, path string) {
|
||||
if !strings.HasPrefix(p, runtimePrefix) {
|
||||
return "", p
|
||||
}
|
||||
rest := strings.TrimPrefix(p, runtimePrefix)
|
||||
id, path, found := strings.Cut(rest, "/")
|
||||
if !found {
|
||||
return id, "/"
|
||||
}
|
||||
return id, "/" + path
|
||||
}
|
||||
|
||||
// isHubPath covers the routes that belong to the hub rather than to any one
|
||||
// runtime: the pane list and the workspace tree the sidebar reads.
|
||||
func isHubPath(p string) bool {
|
||||
p = strings.TrimSuffix(p, "/")
|
||||
return p == "/runtimes" || strings.HasPrefix(p, "/runtimes/") ||
|
||||
p == "/tree" || strings.HasPrefix(p, "/tree/") ||
|
||||
p == "/remotes" || strings.HasPrefix(p, "/remotes/")
|
||||
}
|
||||
|
||||
func isAPIPath(p string) bool {
|
||||
p = strings.TrimSuffix(p, "/")
|
||||
if apiPaths[p] {
|
||||
return true
|
||||
}
|
||||
for _, prefix := range apiPrefixes {
|
||||
if strings.HasPrefix(p+"/", prefix) && p+"/" != prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func main() {
|
||||
// A macOS install re-executes this binary to swap the bundle after the old
|
||||
// process exits. That child must never reach run(), or the swap would race a
|
||||
// second Studio booting on top of the directory it is replacing.
|
||||
if handled, code := update.MaybeRunMacHandoff(os.Args[1:]); handled {
|
||||
os.Exit(code)
|
||||
}
|
||||
os.Exit(start())
|
||||
}
|
||||
|
||||
// start owns the two things that have to outlive run: the log stderr now points
|
||||
// at, and the recover that turns a Go panic into a report. A fatal signal
|
||||
// reaches neither — it reaches the log, which is why the log is here.
|
||||
func start() int {
|
||||
home := config.ReasonixHomeDir()
|
||||
logs, flush, err := openCrashLog(home)
|
||||
defer flush()
|
||||
if err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-next: crash log:", err)
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
_ = crashreport.CaptureStudioPanic(home, version, recovered, debug.Stack())
|
||||
// The re-panic below ends the process, and its trace is written to
|
||||
// the descriptor this flush is about to stop holding open.
|
||||
flush()
|
||||
panic(recovered)
|
||||
}
|
||||
}()
|
||||
if err := run(logs); err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-next:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func run(logs io.Writer) error {
|
||||
ctx := context.Background()
|
||||
// Before the first window: Windows reads this when the taskbar button is
|
||||
// created, and a notification sent under an unclaimed identity is dropped.
|
||||
applyAppIdentity(logs)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// This window is the only client of its kernel, so a system notification
|
||||
// reaches the person who asked for it. A networked server leaves this off.
|
||||
notifySink := windowNotifications(cfg)
|
||||
// One fold behind the status icon: every pane's events on the way through,
|
||||
// plus what they leave running. Built before the first runtime so that
|
||||
// pane's sink is decorated with the rest.
|
||||
tracker := traystate.New(nil)
|
||||
count := windowTelemetry(cfg)
|
||||
watch := func(sink event.Sink) event.Sink { return count(tracker.Watch(paneKey(sink), notifySink(sink))) }
|
||||
|
||||
bc := serve.NewBroadcaster()
|
||||
paneSink := watch(bc)
|
||||
// A window opens where it was left, not where its shortcut happened to point.
|
||||
root := boot.ResolveWorkspaceRoot(lastWorkspace())
|
||||
built, err := boot.BuildRuntime(ctx, boot.Options{
|
||||
Version: version,
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: serve.SessionDirFor(root),
|
||||
Sink: paneSink,
|
||||
Stderr: logs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctrl := built.Controller
|
||||
// No EnsureSessionPath here: minting the file at launch left one empty
|
||||
// transcript behind every time the window opened. The first turn creates
|
||||
// it, and the inbox ensures its own path when it enqueues.
|
||||
|
||||
// Ask/Auto/YOLO is a posture the user set on the composer, not a per-launch
|
||||
// default — the old shell has read this since it had a picker.
|
||||
ctrl.SetToolApprovalMode(cfg.DesktopDefaultToolApprovalMode())
|
||||
// Native panels the shell opens are outside the webview, so the frontend's
|
||||
// catalogue cannot reach them. They follow the desktop interface language, a
|
||||
// separate setting from the kernel's — hence a catalogue read, not the active one.
|
||||
shell := &App{pumps: map[string]context.CancelFunc{}, say: i18n.CatalogFor(cfg.DesktopLanguage()), tracker: tracker}
|
||||
// A first connect can stop for a host key nobody has seen or a locked key.
|
||||
// Both are questions, and the broker is where they live until answered.
|
||||
asks := serve.NewAskBroker(nil)
|
||||
// What this shell is and where it lives; the kernel resolves neither for a
|
||||
// process that is not the application.
|
||||
install := shell.install()
|
||||
// This shell is its own application, so it states itself. A dev copy not
|
||||
// running from a bundle states nothing and still starts: what refuses then
|
||||
// is the swap, by name, rather than a launch with nothing to swap.
|
||||
application, _ := update.LocalApplication(install.Layout)
|
||||
// Read before the hub that serves it: an update can only be acknowledged by
|
||||
// the launch that booted from it, and the transaction on disk right now is
|
||||
// the only one that is.
|
||||
shell.updateHost = appupdate.New(appupdate.Options{
|
||||
Owner: shell,
|
||||
Running: version,
|
||||
Line: update.StudioLine(),
|
||||
Application: application,
|
||||
})
|
||||
// One hub, several panes: each session gets its own runtime, so a second
|
||||
// conversation runs beside the first instead of rebuilding it.
|
||||
hub := serve.NewHub(serve.HubOptions{
|
||||
Serve: cfg.Serve,
|
||||
Surface: surface.Desktop,
|
||||
Grant: grantWindowCapabilities,
|
||||
DecorateSink: watch,
|
||||
OnOpen: shell.startPump,
|
||||
OnClose: func(rt *serve.Runtime) {
|
||||
shell.stopPump(rt)
|
||||
tracker.Drop(paneKey(rt.Events))
|
||||
},
|
||||
Remote: remotehost.New(ctx, version, asks),
|
||||
Asks: asks,
|
||||
Tray: shell,
|
||||
Install: &install,
|
||||
Update: shell.updateHost,
|
||||
})
|
||||
shell.hub = hub
|
||||
srv := serve.New(ctrl, bc, cfg.Serve)
|
||||
srv.SetPaneSink(paneSink)
|
||||
srv.AdoptRuntime(built)
|
||||
// Without this a past save-conflict loop's forks stay on disk forever: the
|
||||
// CLI and the old shell each sweep them, this window is the third host.
|
||||
srv.StartRecoveryGC(ctx)
|
||||
if err := adoptWindowPane(hub, srv, bc, ctrl); err != nil {
|
||||
return err
|
||||
}
|
||||
api := hub.Handler()
|
||||
defer hub.Shutdown()
|
||||
|
||||
assets, err := frontendAssets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return shell.runWindow(api, assets, cfg, tracker)
|
||||
}
|
||||
|
||||
// runWindow is everything from here on: the window, and the two hooks that own
|
||||
// the status icon's life. Split from the assembly above because they fail for
|
||||
// different reasons and read as one long function otherwise.
|
||||
func (a *App) runWindow(api http.Handler, assets fs.FS, cfg *config.Config, tracker *traystate.Tracker) error {
|
||||
shell, hub := a, a.hub
|
||||
return wails.Run(&options.App{
|
||||
Title: "Reasonix Studio",
|
||||
Width: 1440,
|
||||
Height: 900,
|
||||
// The top row is the title bar — project, goal, preset — so a native one
|
||||
// above it costs a whole row to say the app's own name. Same treatment
|
||||
// the existing shell uses; Linux keeps its frame, WebKitGTK has no inset.
|
||||
Frameless: goruntime.GOOS == "windows",
|
||||
// Shown by fitWindow once it has been measured against the screen it
|
||||
// landed on. Sizing a visible window makes the correction a flicker.
|
||||
StartHidden: true,
|
||||
Mac: &mac.Options{TitleBar: mac.TitleBarHiddenInset(), Appearance: mac.DefaultAppearance},
|
||||
Windows: &windows.Options{Theme: windows.SystemDefault},
|
||||
MinWidth: 760,
|
||||
MinHeight: 480,
|
||||
Menu: appMenu(),
|
||||
// Double-clicking the launcher again focuses this window instead of
|
||||
// starting a second one over the same sessions; see single_instance.go.
|
||||
SingleInstanceLock: singleInstanceLock(shell),
|
||||
// Production builds ship without one, so the window had no copy, paste,
|
||||
// or select-all on right-click — in a text editor that reads as broken.
|
||||
EnableDefaultContextMenu: true,
|
||||
Bind: []any{shell},
|
||||
DragAndDrop: dragAndDrop(),
|
||||
OnStartup: func(ctx context.Context) {
|
||||
shell.mu.Lock()
|
||||
shell.ctx = ctx
|
||||
shell.mu.Unlock()
|
||||
applyDockIcon()
|
||||
// Before anything is drawn: a window that does not fit takes its own
|
||||
// title bar off-screen on Windows, and with it the way to move it.
|
||||
fitWindow(ctx)
|
||||
// Panes opened before the window came up have no pump yet; from here
|
||||
// on the hub's OnOpen starts one as each is published.
|
||||
for _, rt := range hub.Runtimes() {
|
||||
shell.startPump(rt)
|
||||
}
|
||||
// No icon, no backgrounding: a window that vanished with no
|
||||
// way back to it would be the worst of both.
|
||||
if icon := startTray(shell, shell.say, tracker, cfg); icon != nil {
|
||||
shell.adoptIcon(icon)
|
||||
shell.background.Store(cfg.DesktopClosesToBackground())
|
||||
}
|
||||
},
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
// In-process HTTP: no port, no CORS, no second transport to keep in
|
||||
// sync with the browser build.
|
||||
Middleware: hubMiddleware(shell, hub, api),
|
||||
},
|
||||
// A loaded renderer is the first point this build has shown it can do
|
||||
// anything; the window itself came up in OnStartup.
|
||||
OnDomReady: shell.acknowledgeUpdateHealth,
|
||||
// The window is held open across the shutdown rather than vanishing into
|
||||
// one; see closing.go. OnShutdown stays as the backstop for exits that
|
||||
// never pass the close button (a signal, a quit from the dock menu).
|
||||
OnBeforeClose: shell.beginClose,
|
||||
OnShutdown: func(context.Context) {
|
||||
shell.closeIcon()
|
||||
hub.Shutdown()
|
||||
closeProcessCatalogs()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func hubMiddleware(shell *App, hub *serve.Hub, api http.Handler) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id, path := splitRuntimePath(r.URL.Path)
|
||||
// The bus has no reconnect handshake, so a reloaded page asks for the
|
||||
// replay resubscribing to /events would have given it.
|
||||
if path == replayPath {
|
||||
// A remote pane has no local controller to ask: its kernel replays
|
||||
// on subscribe, so restarting the pump is the same handover.
|
||||
if rt := hub.Get(id); rt != nil && rt.Local() {
|
||||
rt.Server.Controller().ReplayPendingPromptsWith(func() event.Sink { return rt.Events })
|
||||
} else if rt != nil {
|
||||
shell.restartPump(rt)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if isAPIPath(path) || isHubPath(path) {
|
||||
api.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// appMenu is what makes ⌘C work: a WKWebView takes its editing shortcuts from
|
||||
// the application's Edit menu, and a window without one has no copy, paste, or
|
||||
// undo at all. macOS only — elsewhere the same bar renders as a stray
|
||||
// in-window strip, and those platforms bind the shortcuts themselves.
|
||||
func appMenu() *menu.Menu {
|
||||
if goruntime.GOOS != "darwin" {
|
||||
return nil
|
||||
}
|
||||
m := menu.NewMenu()
|
||||
m.Append(menu.AppMenu())
|
||||
m.Append(menu.EditMenu())
|
||||
m.Append(menu.WindowMenu())
|
||||
return m
|
||||
}
|
||||
|
||||
// App is the one thing the SPA cannot do over HTTP: open the platform's folder
|
||||
// picker. Everything else it needs is a route on the embedded hub. It also owns
|
||||
// the per-runtime event pumps, because the bus is the window's, not a server's.
|
||||
type App struct {
|
||||
hub *serve.Hub
|
||||
ctx context.Context
|
||||
// Text for the native panels this shell opens. Read once at launch from the
|
||||
// desktop interface language; the kernel's own catalogue is a different
|
||||
// setting and must not be swapped out for this.
|
||||
say i18n.Messages
|
||||
|
||||
// background is what the close button does: hide where an icon can bring the
|
||||
// window back, quit where it cannot. Runtime state rather than a config read
|
||||
// per close, because the tray toggles it and the answer must be immediate.
|
||||
background atomic.Bool
|
||||
|
||||
// What the panes add up to, folded as they emit. The icon paints it and the
|
||||
// tray surface answers with it.
|
||||
tracker *traystate.Tracker
|
||||
|
||||
mu sync.Mutex
|
||||
pumps map[string]context.CancelFunc
|
||||
// The status icon this launch got, if the platform gave one. It comes down
|
||||
// only at shutdown — see tray_prefs.go.
|
||||
icon *tray
|
||||
// See closing.go: the window outlives the close button by however long the
|
||||
// session takes to reach disk.
|
||||
closing closeState
|
||||
|
||||
// The update this launch booted from, if it booted from one. Captured
|
||||
// before the window runs and read from the readiness hook, so it is not
|
||||
// mutable state the mutex above has anything to say about.
|
||||
updateHost serve.UpdateHost
|
||||
}
|
||||
|
||||
// windowNotifications returns the sink wrapper every runtime in this window
|
||||
// gets. Off by default and per the shared [notifications] config, so the CLI
|
||||
// and the window answer to one setting rather than each growing its own.
|
||||
func windowNotifications(cfg *config.Config) func(event.Sink) event.Sink {
|
||||
if cfg == nil || !cfg.Notifications.Enabled {
|
||||
return func(sink event.Sink) event.Sink { return sink }
|
||||
}
|
||||
sender := notify.NewPlatformSender()
|
||||
return func(sink event.Sink) event.Sink {
|
||||
return notify.NewSink(sink, sender, cfg.Notifications)
|
||||
}
|
||||
}
|
||||
|
||||
// grantWindowCapabilities opens up what only a local window may do. The single
|
||||
// client is the person in front of it, so the folder picker, the account token
|
||||
// and provider keys are local dialogs rather than remote capabilities — and
|
||||
// every pane gets them, not just the one the window started with.
|
||||
func grantWindowCapabilities(srv *serve.Server) {
|
||||
srv.AllowWorkspaceSwitch()
|
||||
srv.AllowAccountAuth()
|
||||
srv.AllowProviderEdit()
|
||||
// Without this the first-run connection step never appears: /provider-setup
|
||||
// answers 404 while it is off, the window reads that as "nothing to set up",
|
||||
// and a machine with no key lands in the composer where every turn fails.
|
||||
srv.EnableProviderSetup()
|
||||
}
|
||||
|
||||
// startPump forwards one runtime's frames onto the Wails bus under its own
|
||||
// event name. Before OnStartup there is no context to emit into; the shell
|
||||
// pumps whatever the hub already holds once the window comes up.
|
||||
func (a *App) startPump(rt *serve.Runtime) {
|
||||
if rt == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.ctx == nil || a.pumps[rt.ID] != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(a.ctx)
|
||||
a.pumps[rt.ID] = cancel
|
||||
if rt.Local() {
|
||||
go pumpEvents(ctx, rt)
|
||||
return
|
||||
}
|
||||
go pumpRemoteEvents(ctx, rt)
|
||||
}
|
||||
|
||||
// restartPump re-establishes a pane's stream. For a remote one this is also how
|
||||
// a reload replays: the far kernel sends what is pending when it is subscribed.
|
||||
func (a *App) restartPump(rt *serve.Runtime) {
|
||||
a.stopPump(rt)
|
||||
a.startPump(rt)
|
||||
}
|
||||
|
||||
func (a *App) stopPump(rt *serve.Runtime) {
|
||||
if rt == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if cancel := a.pumps[rt.ID]; cancel != nil {
|
||||
cancel()
|
||||
delete(a.pumps, rt.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// currentRoot is the folder the picker should open at: whichever pane the hub
|
||||
// lists first. A window with no pane left has nothing to anchor to.
|
||||
func (a *App) currentRoot() string {
|
||||
for _, rt := range a.hub.Runtimes() {
|
||||
if !rt.Local() {
|
||||
// A remote path names a folder on another machine — opening a picker
|
||||
// there would resolve it against this one's filesystem.
|
||||
continue
|
||||
}
|
||||
if root := rt.Server.Controller().WorkspaceRoot(); root != "" {
|
||||
return root
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PickWorkspace returns the folder the user chose, or "" if they cancelled.
|
||||
// Choosing does not switch anything — the SPA posts the path back to
|
||||
// /workspace, so the browser build reaches the same code by typing one.
|
||||
func (a *App) PickWorkspace() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", nil
|
||||
}
|
||||
// The panel can make a folder — that is what CanCreateDirectories is for —
|
||||
// but nothing said so, and a title reading "open" describes picking one that
|
||||
// exists. Users concluded the app could only open projects, never start one.
|
||||
opts := runtime.OpenDialogOptions{Title: a.say.PickWorkspaceTitle, CanCreateDirectories: true}
|
||||
// Wails refuses to open the panel at all when this points at nothing, and
|
||||
// answers with an error instead — a workspace that has since been moved
|
||||
// would take the picker down with it.
|
||||
if root := a.currentRoot(); root != "" {
|
||||
if st, err := os.Stat(root); err == nil && st.IsDir() {
|
||||
opts.DefaultDirectory = root
|
||||
}
|
||||
}
|
||||
dir, err := runtime.OpenDirectoryDialog(a.ctx, opts)
|
||||
if err != nil {
|
||||
slog.Warn("studio: folder picker", "err", err)
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// OpenExternal hands a link to the platform browser. A WKWebView answers a
|
||||
// target="_blank" click with nothing at all — Wails binds no delegate for it —
|
||||
// so every link in the window is dead until something routes it out. http(s)
|
||||
// only: these come from model output, which may not reach the OS opener with a
|
||||
// scheme of its choosing.
|
||||
func (a *App) OpenExternal(rawURL string) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
slog.Warn("studio: refused to open a link", "url", rawURL)
|
||||
return
|
||||
}
|
||||
runtime.BrowserOpenURL(a.ctx, u.String())
|
||||
}
|
||||
|
||||
// lastWorkspace is the folder this shell was driving when it last closed, or ""
|
||||
// to let boot resolve one from the process working directory.
|
||||
func lastWorkspace() string {
|
||||
for _, dir := range serve.Workspaces() {
|
||||
if st, err := os.Stat(dir); err == nil && st.IsDir() {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// The asset server buffers a response until its handler returns, so GET /events
|
||||
// never delivers a byte inside the shell — the SSE handler is still streaming
|
||||
// when the page gives up. Wails' own bus is the one channel that does push, so
|
||||
// the shell forwards the same broadcaster frames the browser reads over SSE.
|
||||
// The payload is byte-identical either way; only the transport differs.
|
||||
func pumpEvents(ctx context.Context, rt *serve.Runtime) {
|
||||
var ch <-chan serve.Frame
|
||||
var unsubscribe func()
|
||||
// The same handoff GET /events performs: subscribe and replay as one step,
|
||||
// so a prompt already waiting for an answer survives the handover.
|
||||
rt.Server.Controller().ReplayPendingPromptsWith(func() event.Sink {
|
||||
ch, unsubscribe = rt.Events.Subscribe()
|
||||
return event.FuncSink(func(e event.Event) { rt.Events.EmitTo(ch, e) })
|
||||
})
|
||||
defer unsubscribe()
|
||||
name := runtimeEventName(rt.ID)
|
||||
// The bus has nothing resembling a connection, so a page that missed a frame
|
||||
// has no reconnect to notice it by. Same watermark the SSE keepalive sends,
|
||||
// for the same reason: a loss at the end of a turn is otherwise invisible.
|
||||
watermark := time.NewTicker(serve.SSEWatermarkInterval)
|
||||
defer watermark.Stop()
|
||||
for {
|
||||
select {
|
||||
case f, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtime.EventsEmit(ctx, name, string(f.Data))
|
||||
case <-watermark.C:
|
||||
runtime.EventsEmit(ctx, name, fmt.Sprintf(`{"kind":"stream_watermark","seq":%d}`, rt.Events.Watermark()))
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeEventName is the bus channel one pane listens on. Panes run at the
|
||||
// same time, so a single channel would interleave two conversations into both.
|
||||
func runtimeEventName(id string) string { return wailsEventName + ":" + id }
|
||||
|
||||
func frontendAssets() (fs.FS, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, dir := range []string{
|
||||
filepath.Join(filepath.Dir(exe), "frontend-next", "dist"),
|
||||
filepath.Join("..", "frontend-next", "dist"),
|
||||
filepath.Join("frontend-next", "dist"),
|
||||
} {
|
||||
if st, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !st.IsDir() {
|
||||
return os.DirFS(dir), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("frontend-next/dist not found: run `pnpm build` in desktop/frontend-next first")
|
||||
}
|
||||
|
||||
// paneKey names a pane by the broadcaster it emits through: the hub hands that
|
||||
// to the sink decorator before the pane has an id of its own, and hands the
|
||||
// same one back as rt.Events when the pane closes.
|
||||
func paneKey(sink any) string { return fmt.Sprintf("%p", sink) }
|
||||
@@ -1,68 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The shell decides per request whether a path is the kernel's or the SPA's. A
|
||||
// kernel path that falls through answers with index.html, and the frontend then
|
||||
// fails parsing HTML as JSON — a failure that reads like a broken endpoint.
|
||||
func TestIsAPIPathCoversResourceFamilies(t *testing.T) {
|
||||
for _, p := range []string{
|
||||
"/status", "/mcp", "/mcp/", "/mcp/reconnect", "/mcp/enabled",
|
||||
"/skills", "/skills/enabled", "/inbox/items", "/workspaces",
|
||||
"/hooks", "/hooks/dry-run", "/memory", "/memory/forget",
|
||||
"/network", "/network/diagnose",
|
||||
} {
|
||||
if !isAPIPath(p) {
|
||||
t.Errorf("isAPIPath(%q) = false, want true", p)
|
||||
}
|
||||
}
|
||||
// Anything not claimed here has to reach the assets, or a deep link stops
|
||||
// rendering the app.
|
||||
for _, p := range []string{"/", "/sessions/abc", "/assets/app.js", "/mcpx", "/skillset"} {
|
||||
if isAPIPath(p) {
|
||||
t.Errorf("isAPIPath(%q) = true, want false", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The list above is hand-maintained, and hand-maintained lists drift: the hooks,
|
||||
// memory and network panels shipped reading endpoints this shell never routed,
|
||||
// so all three came up empty in the app and worked in the browser. Reading the
|
||||
// paths back out of the client is what makes the next one fail here instead.
|
||||
func TestEveryPathTheFrontendCallsIsRouted(t *testing.T) {
|
||||
const client = "../frontend-next/src/port/sse.ts"
|
||||
src, err := os.ReadFile(client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Anchored on the call, so this.base + "/plugins/" + name + "/export" does
|
||||
// not read as a request for "/export". The type argument is not cosmetic:
|
||||
// without it this.get<T>("/x") matched nothing — 24 of 60 paths went unseen.
|
||||
literal := regexp.MustCompile(`this\.(?:\w+(?:<[^()"]*>)?\(|base \+ )\s*"(/[a-z0-9][a-z0-9/-]*)"`)
|
||||
seen := map[string]bool{}
|
||||
for _, m := range literal.FindAllStringSubmatch(string(src), -1) {
|
||||
// A trailing slash means an id follows, so probe the family rather
|
||||
// than the bare prefix, which isAPIPath deliberately rejects.
|
||||
path := m[1]
|
||||
if strings.HasSuffix(path, "/") {
|
||||
path += "x"
|
||||
}
|
||||
// The replay endpoint is the shell's own; it is answered by the
|
||||
// middleware before the API ever sees it.
|
||||
if seen[path] || path == replayPath {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
if !isAPIPath(path) {
|
||||
t.Errorf("%s calls %q but the shell routes it to the assets, so it answers index.html", client, path)
|
||||
}
|
||||
}
|
||||
if len(seen) < 20 {
|
||||
t.Fatalf("only found %d paths in %s — the extraction broke, not the routing", len(seen), client)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// plugin_export.go — handing a packed plugin package to the user.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/pluginpkg"
|
||||
)
|
||||
|
||||
// PluginExport is what the window answers with: where the archive landed, and
|
||||
// the variables its configuration no longer carries. An empty path is the
|
||||
// user cancelling, which is not a failure.
|
||||
type PluginExport struct {
|
||||
Path string `json:"path"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
|
||||
// SavePluginExport packs an installed package and writes it wherever the native
|
||||
// dialog points. A WKWebView starts no downloads of its own, so the browser
|
||||
// build's <a download> is not a path this window has — without this binding the
|
||||
// export button would do nothing at all on macOS.
|
||||
func (a *App) SavePluginExport(name string) (PluginExport, error) {
|
||||
if a.ctx == nil {
|
||||
return PluginExport{}, nil
|
||||
}
|
||||
home := config.ReasonixHomeDir()
|
||||
st, err := pluginpkg.LoadState(home)
|
||||
if err != nil {
|
||||
return PluginExport{}, err
|
||||
}
|
||||
root := ""
|
||||
for _, p := range st.Plugins {
|
||||
if p.Name == name {
|
||||
root = pluginpkg.ResolveRoot(home, p.Root)
|
||||
break
|
||||
}
|
||||
}
|
||||
if root == "" {
|
||||
return PluginExport{}, fmt.Errorf("plugin %q is not installed", name)
|
||||
}
|
||||
archive, required, err := pluginpkg.Export(name, root)
|
||||
if err != nil {
|
||||
return PluginExport{}, err
|
||||
}
|
||||
// Packed before the dialog opens: a package that cannot be packed should
|
||||
// say so instead of asking where to put a file it will never write.
|
||||
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
Title: "导出插件包",
|
||||
DefaultFilename: name + ".zip",
|
||||
})
|
||||
if err != nil {
|
||||
return PluginExport{}, err
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return PluginExport{Required: required}, nil
|
||||
}
|
||||
if err := os.WriteFile(path, archive, 0o644); err != nil {
|
||||
return PluginExport{}, err
|
||||
}
|
||||
return PluginExport{Path: path, Required: required}, nil
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// Provider kinds register themselves from init, so a binary only has the ones it
|
||||
// links. The shell shipped without these imports and every Anthropic model in
|
||||
// the picker failed at switch time with "unknown kind" — the settings pane could
|
||||
// offer it because the catalog reads config, which does not need the factory.
|
||||
func TestEveryProviderKindTheConfigAcceptsIsLinked(t *testing.T) {
|
||||
registered := map[string]bool{}
|
||||
for _, kind := range provider.Kinds() {
|
||||
registered[kind] = true
|
||||
}
|
||||
// The kinds providerEntryFrom lets a user save, plus the ones the curated
|
||||
// presets ship with.
|
||||
for _, kind := range []string{"openai", "anthropic", "responses"} {
|
||||
if !registered[kind] {
|
||||
t.Errorf("provider kind %q is not linked into the shell, so switching to one fails at runtime", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the presets can write into a config, this binary has to be able to
|
||||
// build. A preset naming an unlinked kind is a source the user can add and then
|
||||
// never select.
|
||||
func TestEveryPresetKindIsLinked(t *testing.T) {
|
||||
registered := map[string]bool{}
|
||||
for _, kind := range provider.Kinds() {
|
||||
registered[kind] = true
|
||||
}
|
||||
for _, preset := range config.CuratedProviderPresets() {
|
||||
for _, entry := range preset.Entries {
|
||||
if entry.Kind != "" && !registered[entry.Kind] {
|
||||
t.Errorf("preset %q wants kind %q, which is not linked into the shell", preset.ID, entry.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"reasonix/internal/serve"
|
||||
)
|
||||
|
||||
const (
|
||||
remoteRetryFloor = time.Second
|
||||
remoteRetryCeiling = 30 * time.Second
|
||||
// Long enough for a remote kernel mid-turn to answer, short enough that a
|
||||
// link which died without an RST is noticed rather than waited on forever.
|
||||
remoteHeaderTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// pumpRemoteEvents forwards a proxied pane's stream onto the bus a local pane's
|
||||
// frames use. The hub's proxy already streams the same frames to a page that
|
||||
// can read SSE; this exists because the page inside this shell reads the bus
|
||||
// for every pane, remote included — the asset server holds a response until its
|
||||
// handler returns — so it retires with that bus rather than before it.
|
||||
func pumpRemoteEvents(ctx context.Context, rt *serve.Runtime) {
|
||||
ep, ok := rt.Remote()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
client := &http.Client{Transport: &http.Transport{ResponseHeaderTimeout: remoteHeaderTimeout}}
|
||||
name := runtimeEventName(rt.ID)
|
||||
// Where to resume. The remote numbers its resumable frames and honours
|
||||
// Last-Event-ID, so a link that drops mid-turn costs only what its replay
|
||||
// log has since dropped — not the rest of the turn.
|
||||
var after int64
|
||||
wait := remoteRetryFloor
|
||||
for ctx.Err() == nil {
|
||||
reached, err := streamRemoteEvents(ctx, client, ep, name, after)
|
||||
if reached > after {
|
||||
after = reached
|
||||
// Frames arrived, so whatever ended the stream was not a link that
|
||||
// cannot carry one. The next drop starts its backoff over.
|
||||
wait = remoteRetryFloor
|
||||
}
|
||||
if ctx.Err() != nil || (err == nil && reached == 0 && after == 0) {
|
||||
// A clean end with nothing ever delivered is the remote closing the
|
||||
// pane rather than a connection to retry.
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
}
|
||||
if wait = wait * 2; wait > remoteRetryCeiling {
|
||||
wait = remoteRetryCeiling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// streamRemoteEvents reads one connection's worth of frames and returns the
|
||||
// highest resumable id it saw.
|
||||
func streamRemoteEvents(ctx context.Context, client *http.Client, ep serve.RemoteEndpoint, name string, after int64) (int64, error) {
|
||||
// Base names this pane's runtime over there. Without it the request lands
|
||||
// on that hub's default runtime — the pane would send its turns to one
|
||||
// conversation and listen to another, which reads as nothing happening.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+ep.Addr+ep.Base+"/events", nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("Cookie", serve.TokenCookie+"="+ep.Token)
|
||||
if after > 0 {
|
||||
req.Header.Set("Last-Event-ID", strconv.FormatInt(after, 10))
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("remote events: %s", resp.Status)
|
||||
}
|
||||
|
||||
var reached int64
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return reached, err
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
switch {
|
||||
case strings.HasPrefix(line, "id:"):
|
||||
if seq, err := strconv.ParseInt(strings.TrimSpace(line[3:]), 10, 64); err == nil {
|
||||
reached = seq
|
||||
}
|
||||
case strings.HasPrefix(line, "data:"):
|
||||
runtime.EventsEmit(ctx, name, strings.TrimSpace(line[5:]))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Not every kernel route is reached through the typed client: the theme picker
|
||||
// puts a pack's preview straight in an <img src>, so scanning sse.ts said the
|
||||
// routing was fine while the picture rendered as a broken image. Reading the
|
||||
// server's own registrations covers both kinds of caller at once.
|
||||
func TestEveryRouteTheKernelRegistersIsRouted(t *testing.T) {
|
||||
files, err := filepath.Glob(filepath.Join("..", "..", "internal", "serve", "*.go"))
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no serve sources found: %v", err)
|
||||
}
|
||||
handler := regexp.MustCompile(`mux\.HandleFunc\("(?:GET|POST|PUT|PATCH|DELETE) (/[^"]*)", ([sh]\.\w+)\)`)
|
||||
wildcard := regexp.MustCompile(`\{[^}]*\}`)
|
||||
seen := map[string]bool{}
|
||||
for _, file := range files {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range handler.FindAllStringSubmatch(string(src), -1) {
|
||||
route, served := m[1], m[2]
|
||||
if seen[route] {
|
||||
continue
|
||||
}
|
||||
seen[route] = true
|
||||
// s.index answers with the SPA itself, and /assets belongs to the
|
||||
// bundle the shell ships — routing either one to the kernel would
|
||||
// take the app's own entry point and stylesheet away from it.
|
||||
if served == "s.index" || strings.HasPrefix(route, "/assets/") {
|
||||
continue
|
||||
}
|
||||
probe := wildcard.ReplaceAllString(route, "x")
|
||||
if !isAPIPath(probe) && !isHubPath(probe) {
|
||||
t.Errorf("%s registers %q but the shell routes %q to the assets, so it answers index.html", filepath.Base(file), route, probe)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(seen) < 40 {
|
||||
t.Fatalf("only found %d routes — the extraction broke, not the routing", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
// The dev proxy keeps its own copy of the same list, and a path missing there
|
||||
// answers with the SPA shell exactly like a path missing here — the difference
|
||||
// is that it only shows up while developing against a live kernel, which is
|
||||
// when nobody is looking at this file. Both lists are checked against the same
|
||||
// source of truth: what the server actually registers.
|
||||
func TestEveryRouteTheKernelRegistersIsProxiedInDev(t *testing.T) {
|
||||
config, err := os.ReadFile(filepath.Join("..", "frontend-next", "vite.config.ts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block := regexp.MustCompile(`(?s)const ROUTES = \[(.*?)\]`).FindSubmatch(config)
|
||||
if block == nil {
|
||||
t.Fatal("vite.config.ts no longer declares ROUTES")
|
||||
}
|
||||
proxied := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`"(/[^"]*)"`).FindAllSubmatch(block[1], -1) {
|
||||
proxied[string(m[1])] = true
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join("..", "..", "internal", "serve", "*.go"))
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no serve sources found: %v", err)
|
||||
}
|
||||
handler := regexp.MustCompile(`mux\.HandleFunc\("(?:GET|POST|PUT|PATCH|DELETE) (/[^"]*)", ([sh]\.\w+)\)`)
|
||||
for _, file := range files {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range handler.FindAllStringSubmatch(string(src), -1) {
|
||||
route, served := m[1], m[2]
|
||||
if served == "s.index" || strings.HasPrefix(route, "/assets/") || route == "/" {
|
||||
continue
|
||||
}
|
||||
// The proxy matches by prefix, so a family's root covers its leaves.
|
||||
head := "/" + strings.SplitN(strings.TrimPrefix(route, "/"), "/", 2)[0]
|
||||
if !proxied[head] && !proxied[route] {
|
||||
t.Errorf("%s registers %q but vite.config.ts does not proxy %q, so `pnpm dev` against a live kernel answers index.html",
|
||||
filepath.Base(file), route, head)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Studio and launch it the way macOS requires: from inside an .app bundle.
|
||||
#
|
||||
# A bare binary run from a terminal draws its window fine, so the difference is
|
||||
# invisible until you open a native panel — NSOpenPanel takes focus and closes
|
||||
# again in the same beat, and "Add a folder…" looks broken. LaunchServices only
|
||||
# treats a process as a real GUI app when it lives in a bundle with an
|
||||
# Info.plist, so that is what this builds.
|
||||
#
|
||||
# The bundle is self-contained on purpose: main.go looks for the frontend next
|
||||
# to the executable first (see assetsFS), so a symlink there frees the launch
|
||||
# from whatever working directory `open` happens to use — it uses "/".
|
||||
set -euo pipefail
|
||||
|
||||
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
desktop=$(cd "$here/.." && pwd)
|
||||
app=${STUDIO_APP:-/tmp/ReasonixStudio.app}
|
||||
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
echo "run-studio.sh is the macOS launch path; elsewhere just run the binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# emsdk's clang shadows the toolchain and breaks cgo with an -arch error.
|
||||
PATH=$(echo "$PATH" | tr ':' '\n' | grep -v emsdk | paste -sd: -)
|
||||
export PATH
|
||||
|
||||
echo "==> frontend"
|
||||
(cd "$desktop/frontend-next" && pnpm build >/dev/null)
|
||||
|
||||
echo "==> shell"
|
||||
(cd "$desktop" && go build -tags "desktop production" -o "$app.bin" ./next)
|
||||
|
||||
echo "==> bundle"
|
||||
rm -rf "$app"
|
||||
mkdir -p "$app/Contents/MacOS"
|
||||
mv "$app.bin" "$app/Contents/MacOS/ReasonixStudio"
|
||||
# Copied, not linked. A symlink pointing outside the bundle makes the whole
|
||||
# thing fail codesign --strict, and an app macOS cannot verify gets no stable
|
||||
# identity — which is what silently denied it Local Network access. Only
|
||||
# dist is needed (see frontendAssets), so this stays small.
|
||||
mkdir -p "$app/Contents/MacOS/frontend-next"
|
||||
cp -R "$desktop/frontend-next/dist" "$app/Contents/MacOS/frontend-next/dist"
|
||||
cat > "$app/Contents/Info.plist" <<'PLIST'
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<!-- Named apart from the released app on purpose: they have different
|
||||
bundle identifiers, so macOS lists their privacy grants separately,
|
||||
and two identical rows is how you grant one and test the other. -->
|
||||
<key>CFBundleName</key><string>Reasonix Studio (dev)</string>
|
||||
<key>CFBundleExecutable</key><string>ReasonixStudio</string>
|
||||
<key>CFBundleIdentifier</key><string>io.reasonix.studio.dev</string>
|
||||
<key>CFBundleVersion</key><string>0.0.0</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.0.0</string>
|
||||
<key>LSMinimumSystemVersion</key><string>10.13.0</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Reasonix opens workspaces on other machines over SSH, including ones on this network.</string>
|
||||
<key>NSHighResolutionCapable</key><string>true</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
# Signed with a real identity where one exists, ad-hoc otherwise. This is not
|
||||
# about Gatekeeper — an ad-hoc signature's hash changes with every build, so
|
||||
# macOS treats each one as a new app and the Local Network grant a LAN host
|
||||
# needs is asked for again, or silently missing, every time. A development
|
||||
# certificate keeps the identity stable across rebuilds.
|
||||
identity=${STUDIO_SIGN_IDENTITY:-}
|
||||
if [ -z "$identity" ]; then
|
||||
identity=$(security find-identity -v -p codesigning 2>/dev/null |
|
||||
grep -o '"Apple Development: [^"]*"' | head -1 | tr -d '"')
|
||||
fi
|
||||
# --deep, the way scripts/studio-build.sh signs the released bundle: the
|
||||
# frontend sits beside the binary, and everything under Contents/MacOS is code
|
||||
# as far as codesign is concerned, so each file needs its own signature.
|
||||
if [ -n "$identity" ]; then
|
||||
codesign --force --deep --sign "$identity" "$app" >/dev/null 2>&1 ||
|
||||
codesign --force --deep --sign - "$app" >/dev/null 2>&1
|
||||
else
|
||||
codesign --force --deep --sign - "$app" >/dev/null 2>&1
|
||||
fi
|
||||
codesign --verify --deep --strict "$app" >/dev/null 2>&1 ||
|
||||
echo "warning: the bundle does not verify; macOS will treat it as unidentified" >&2
|
||||
|
||||
# The window process does not act on SIGTERM: pkill matched it and left it
|
||||
# running, so every rebuild opened a second copy on top of the one before, and
|
||||
# they accumulated for as long as the machine stayed up. Ask first, then insist.
|
||||
pat="$app/Contents/MacOS/ReasonixStudio"
|
||||
pkill -f "$pat" 2>/dev/null || true
|
||||
for _ in 1 2 3 4; do
|
||||
pgrep -f "$pat" >/dev/null 2>&1 || break
|
||||
sleep 0.25
|
||||
done
|
||||
pkill -9 -f "$pat" 2>/dev/null || true
|
||||
# REASONIX_DEV waives the single-instance lock, which is keyed to the data home
|
||||
# rather than the bundle: without it this launch would be handed to an installed
|
||||
# Studio window instead of opening the one just built. `open` does not pass the
|
||||
# shell's environment on, hence --env.
|
||||
open --env REASONIX_DEV=1 "$app"
|
||||
echo "==> $app"
|
||||
@@ -1,35 +0,0 @@
|
||||
//go:build desktop
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// SaveText writes text the window produced to a file the user picks. A webview
|
||||
// starts no downloads of its own, so anything assembled in the frontend — the
|
||||
// trajectory is the first — needs the shell to put it on disk. Empty path means
|
||||
// the dialog was dismissed, which is an answer, not a failure.
|
||||
func (a *App) SaveText(name, content string) (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", nil
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return "", fmt.Errorf("nothing to save")
|
||||
}
|
||||
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
Title: "保存",
|
||||
DefaultFilename: name,
|
||||
})
|
||||
if err != nil || strings.TrimSpace(path) == "" {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// session.go — which session a window opens on.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/serve"
|
||||
)
|
||||
|
||||
// adoptWindowPane opens the window on the session it inherited, or on a new one
|
||||
// when that session is still being written — by another window, or by one of our
|
||||
// own processes taking its time to exit. A window inherits its session rather
|
||||
// than naming one, so a held session is not an error to report but a session to
|
||||
// stop wanting: writing into it anyway is what forks the transcript.
|
||||
func adoptWindowPane(hub *serve.Hub, srv *serve.Server, bc *serve.Broadcaster, ctrl *control.Controller) error {
|
||||
_, err := hub.Adopt(srv, bc)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
slog.Warn("studio: the last session is still being written; opening a new one", "err", err)
|
||||
if err := ctrl.NewSession(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = hub.Adopt(srv, bc)
|
||||
return err
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// single_instance.go — one Studio window per data home.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
|
||||
"reasonix/internal/instanceid"
|
||||
)
|
||||
|
||||
// singleInstanceLock routes a second launch back to the window already up.
|
||||
// Without it the second process opens its own, and two windows over one data
|
||||
// home are two writers for every session file the sidebar lists.
|
||||
func singleInstanceLock(shell *App) *options.SingleInstanceLock {
|
||||
// A dev build has to be able to run beside an installed one.
|
||||
if os.Getenv("REASONIX_DEV") != "" {
|
||||
return nil
|
||||
}
|
||||
return &options.SingleInstanceLock{
|
||||
UniqueId: instanceid.Current(),
|
||||
OnSecondInstanceLaunch: func(options.SecondInstanceData) { shell.showWindow() },
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
|
||||
"reasonix/internal/instanceid"
|
||||
)
|
||||
|
||||
func TestSecondLaunchRestoresTheRunningWindow(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
lock := singleInstanceLock(&App{})
|
||||
if lock == nil {
|
||||
t.Fatal("no lock: a second launch would open its own window over the same sessions")
|
||||
}
|
||||
if !strings.HasPrefix(lock.UniqueId, instanceid.Prefix+".") {
|
||||
t.Fatalf("UniqueId = %q, want the %s prefix", lock.UniqueId, instanceid.Prefix)
|
||||
}
|
||||
if lock.OnSecondInstanceLaunch == nil {
|
||||
t.Fatal("nothing answers a second launch, so it would be swallowed instead of raising the window")
|
||||
}
|
||||
lock.OnSecondInstanceLaunch(options.SecondInstanceData{})
|
||||
}
|
||||
|
||||
func TestDevBuildRunsBesideAnInstalledOne(t *testing.T) {
|
||||
t.Setenv("REASONIX_DEV", "1")
|
||||
if lock := singleInstanceLock(&App{}); lock != nil {
|
||||
t.Fatalf("dev build took the lock (%q); it would be sent to the installed window instead", lock.UniqueId)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/surface"
|
||||
"reasonix/internal/telemetry"
|
||||
)
|
||||
|
||||
// windowTelemetry starts this surface's reporter and returns the decorator that
|
||||
// counts a pane's turns. The window offers the launch ping and the aggregate
|
||||
// counters as two consents, so each is honored alone: counters declined still
|
||||
// pings, the ping declined still counts. Mode is "on" only once the config has
|
||||
// said so — Enabled still refuses a development build, CI, or DO_NOT_TRACK.
|
||||
func windowTelemetry(cfg *config.Config) func(event.Sink) event.Sink {
|
||||
undecorated := func(sink event.Sink) event.Sink { return sink }
|
||||
if !cfg.DesktopMetrics() && !cfg.DesktopTelemetry() {
|
||||
return undecorated
|
||||
}
|
||||
reporter := telemetry.Start(telemetry.Options{
|
||||
Mode: "on",
|
||||
Version: version,
|
||||
Surface: surface.Desktop,
|
||||
HomeDir: config.ReasonixHomeDir(),
|
||||
Proxy: cfg.NetworkProxySpec(),
|
||||
Interactive: true,
|
||||
Language: cfg.Language,
|
||||
SuppressPing: !cfg.DesktopTelemetry(),
|
||||
})
|
||||
if !cfg.DesktopMetrics() {
|
||||
return undecorated
|
||||
}
|
||||
return reporter.Wrap
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/theme"
|
||||
)
|
||||
|
||||
// The vocabulary a theme pack writes in is the kernel's (internal/theme.Tokens
|
||||
// decides what is a token and what its value may look like), while the mapping
|
||||
// onto CSS variables is the frontend's. Neither half can see the other, so a
|
||||
// token added on one side alone is accepted and then silently never painted —
|
||||
// or painted and then rejected. This is where the two are held together.
|
||||
func TestThemeTokenVocabularyMatchesTheFrontend(t *testing.T) {
|
||||
src, err := os.ReadFile(filepath.Join("..", "frontend-next", "src", "ui", "theme.ts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, ok := surfaceBlock(string(src))
|
||||
if !ok {
|
||||
t.Fatal("theme.ts no longer declares a SURFACE map; this test cannot see the mapping")
|
||||
}
|
||||
mapped := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`(?m)^\s+(\w+):\s*\[`).FindAllStringSubmatch(block, -1) {
|
||||
mapped[m[1]] = true
|
||||
}
|
||||
for _, name := range theme.TokenNames() {
|
||||
if !mapped[name] {
|
||||
t.Errorf("kernel accepts token %q but theme.ts maps it to nothing, so a pack setting it changes nothing", name)
|
||||
}
|
||||
delete(mapped, name)
|
||||
}
|
||||
for name := range mapped {
|
||||
t.Errorf("theme.ts maps %q but the kernel drops it as unknown, so a pack setting it is warned about and ignored", name)
|
||||
}
|
||||
}
|
||||
|
||||
func surfaceBlock(src string) (string, bool) {
|
||||
start := strings.Index(src, "const SURFACE")
|
||||
if start < 0 {
|
||||
return "", false
|
||||
}
|
||||
end := strings.Index(src[start:], "\n};")
|
||||
if end < 0 {
|
||||
return "", false
|
||||
}
|
||||
return src[start : start+end], true
|
||||
}
|
||||
|
||||
// The meanings a pack cannot take. A theme that could recolour these would let
|
||||
// a failure render as success, so the list is pinned on both sides: the kernel
|
||||
// refuses them by omission from its vocabulary, the frontend by never mapping
|
||||
// them, and this says the frontend still remembers why.
|
||||
func TestReservedMeaningsAreNotThemeTokens(t *testing.T) {
|
||||
for _, reserved := range []string{"ok", "warn", "err", "net", "deleg", "add", "del", "focus"} {
|
||||
if _, taken := theme.Tokens[reserved]; taken {
|
||||
t.Errorf("%q became a theme token; a pack can now recolour a meaning", reserved)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Names no literal return states: lsp.posTool is built from a slice of
|
||||
// literals, complete_subtask returns a constant, and skill.subagentSkillTool
|
||||
// takes its name from the skill it was installed for.
|
||||
var extraKernelToolNames = []string{
|
||||
"lsp_definition", "lsp_references", "lsp_hover", "complete_subtask",
|
||||
}
|
||||
|
||||
// Drawn from a card but registered by nobody: web_search is the provider's own,
|
||||
// use_capability the proxy every other name arrives through, guardian the host's.
|
||||
var unregisteredCardNames = []string{"web_search", "use_capability", "guardian_assessment"}
|
||||
|
||||
// Below this the scan has stopped seeing the tree rather than found it clean.
|
||||
const minKernelTools = 45
|
||||
|
||||
func TestEveryKernelToolHasACardIdentity(t *testing.T) {
|
||||
kernel := kernelToolNames(t)
|
||||
if len(kernel) < minKernelTools {
|
||||
t.Fatalf("found only %d tool names; the scan is no longer reading the registrations", len(kernel))
|
||||
}
|
||||
|
||||
labels := tsRecordKeys(t, filepath.Join("..", "frontend-next", "src", "ui", "icons.ts"), "LABEL")
|
||||
for _, name := range kernel {
|
||||
if !labels[name] {
|
||||
t.Errorf("%s has no entry in icons.ts LABEL: it renders as its own id", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other direction: a name in the tables the kernel no longer registers is a
|
||||
// row nobody can reach, which is what a rename leaves behind.
|
||||
func TestNoCardIdentityNamesAToolTheKernelDoesNotRegister(t *testing.T) {
|
||||
kernel := map[string]bool{}
|
||||
for _, n := range kernelToolNames(t) {
|
||||
kernel[n] = true
|
||||
}
|
||||
for _, n := range unregisteredCardNames {
|
||||
kernel[n] = true
|
||||
}
|
||||
|
||||
for _, file := range []struct{ path, record string }{
|
||||
{filepath.Join("..", "frontend-next", "src", "ui", "icons.ts"), "LABEL"},
|
||||
{filepath.Join("..", "frontend-next", "src", "ui", "Sym.tsx"), "BY_TOOL"},
|
||||
} {
|
||||
for name := range tsRecordKeys(t, file.path, file.record) {
|
||||
if !kernel[name] {
|
||||
t.Errorf("%s names %q, which no kernel registration returns", file.record, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
toolName = regexp.MustCompile(`func \((?:\w+ )?\*?(\w+)\) Name\(\) string\s*{\s*return "([a-z_]+)"`)
|
||||
toolSchema = regexp.MustCompile(`func \((?:\w+ )?\*?(\w+)\) Schema\(\) json\.RawMessage`)
|
||||
)
|
||||
|
||||
func kernelToolNames(t *testing.T) []string {
|
||||
t.Helper()
|
||||
seen := map[string]bool{}
|
||||
for _, name := range extraKernelToolNames {
|
||||
seen[name] = true
|
||||
}
|
||||
named := map[string]map[string]string{}
|
||||
schemas := map[string]map[string]bool{}
|
||||
root := filepath.Join("..", "..", "internal")
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return err
|
||||
}
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkg := filepath.Dir(path)
|
||||
if named[pkg] == nil {
|
||||
named[pkg], schemas[pkg] = map[string]string{}, map[string]bool{}
|
||||
}
|
||||
for _, m := range toolSchema.FindAllStringSubmatch(string(src), -1) {
|
||||
schemas[pkg][m[1]] = true
|
||||
}
|
||||
for _, m := range toolName.FindAllStringSubmatch(string(src), -1) {
|
||||
named[pkg][m[1]] = m[2]
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for pkg, types := range named {
|
||||
for typ, name := range types {
|
||||
if schemas[pkg][typ] {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for n := range seen {
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The keys of a `const <name>: Record<string, string> = { … }` literal, read as
|
||||
// text because the two sides meet nowhere a compiler could check them.
|
||||
func tsRecordKeys(t *testing.T, path, record string) map[string]bool {
|
||||
t.Helper()
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, body, ok := strings.Cut(string(src), "const "+record)
|
||||
if !ok {
|
||||
t.Fatalf("%s no longer declares %s; this test cannot see the table", path, record)
|
||||
}
|
||||
table, _, ok := strings.Cut(body, "\n};")
|
||||
if !ok {
|
||||
t.Fatalf("%s: %s is not closed by a line of its own", path, record)
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`([a-z_][a-z0-9_]*)\s*:\s*"`).FindAllStringSubmatch(table, -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
t.Fatalf("%s: read no keys out of %s", path, record)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
// tray.go — the status icon, which is the only surface a hidden window has.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/i18n"
|
||||
"reasonix/internal/serve"
|
||||
"reasonix/internal/traystate"
|
||||
)
|
||||
|
||||
// tray owns the icon and the menu under it. It reports the fold and nothing
|
||||
// else: every action it offers is a verb the window already has, so there is
|
||||
// one implementation of "quit" and one of "come back".
|
||||
type tray struct {
|
||||
shell *App
|
||||
say i18n.Messages
|
||||
stop func()
|
||||
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
status *systray.MenuItem
|
||||
stayOpen *systray.MenuItem
|
||||
last traystate.State
|
||||
}
|
||||
|
||||
// jobPollInterval is how often the job count is refreshed. A job announces its
|
||||
// life in a sentence and nowhere else, so this asks rather than listens, and a
|
||||
// status line is not a scheduler.
|
||||
const jobPollInterval = 5 * time.Second
|
||||
|
||||
// startTray brings the icon up beside the window's own loop and reports whether
|
||||
// there is one. A shell with no icon must never background its window: hiding
|
||||
// it would leave a running session with no way back to it.
|
||||
func startTray(shell *App, say i18n.Messages, tracker *traystate.Tracker, cfg *config.Config) *tray {
|
||||
if cfg != nil && cfg.DesktopTray() == "off" {
|
||||
return nil
|
||||
}
|
||||
t := &tray{shell: shell, say: say, done: make(chan struct{})}
|
||||
t.stop = runTray(t.ready, func() {})
|
||||
tracker.SetOnChange(t.show)
|
||||
go t.pollJobs(tracker)
|
||||
return t
|
||||
}
|
||||
|
||||
// pollJobs keeps the count of what the window would leave behind fresh.
|
||||
func (t *tray) pollJobs(tracker *traystate.Tracker) {
|
||||
tick := time.NewTicker(jobPollInterval)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.done:
|
||||
return
|
||||
case <-tick.C:
|
||||
tracker.SetJobs(t.runningJobs())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runningJobs counts this machine's own. A remote pane's jobs live on the far
|
||||
// kernel, and answering for them from here would be a number nobody could act
|
||||
// on from this menu.
|
||||
func (t *tray) runningJobs() int {
|
||||
if t.shell == nil || t.shell.hub == nil {
|
||||
return 0
|
||||
}
|
||||
running := 0
|
||||
for _, rt := range t.shell.hub.Runtimes() {
|
||||
if rt == nil || rt.Server == nil || !rt.Local() {
|
||||
continue
|
||||
}
|
||||
running += len(rt.Server.Controller().Jobs())
|
||||
}
|
||||
return running
|
||||
}
|
||||
|
||||
// ready builds the menu once the platform has an icon to hang it on.
|
||||
func (t *tray) ready() {
|
||||
systray.SetIcon(moodIcon(traystate.MoodIdle))
|
||||
systray.SetTitle("Reasonix")
|
||||
systray.SetTooltip("Reasonix Studio")
|
||||
systray.SetOnTapped(t.shell.showWindow)
|
||||
|
||||
open := systray.AddMenuItem(t.say.TrayOpen, "")
|
||||
systray.AddSeparator()
|
||||
status := systray.AddMenuItem(t.say.TrayIdle, "")
|
||||
status.Disable()
|
||||
stay := systray.AddMenuItemCheckbox(t.say.TrayCloseToTray, "", t.shell.background.Load())
|
||||
systray.AddSeparator()
|
||||
quit := systray.AddMenuItem(t.say.TrayQuit, "")
|
||||
|
||||
t.mu.Lock()
|
||||
t.status, t.stayOpen = status, stay
|
||||
last := t.last
|
||||
t.mu.Unlock()
|
||||
t.show(last)
|
||||
|
||||
go t.pump(open.ClickedCh, stay.ClickedCh, quit.ClickedCh)
|
||||
}
|
||||
|
||||
// pump answers the menu. One goroutine for the three channels, because a click
|
||||
// is a rare event and three of them would only be three ways to leak.
|
||||
func (t *tray) pump(open, stay, quit <-chan struct{}) {
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-open:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
t.shell.showWindow()
|
||||
case _, ok := <-stay:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
t.toggleBackground()
|
||||
case _, ok := <-quit:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
t.shell.quitFromTray()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toggleBackground flips what the close button does through the same service
|
||||
// the settings panel writes with, so one switch has one implementation. The
|
||||
// hub applies it back, which is what moves the flag and this checkbox.
|
||||
func (t *tray) toggleBackground() {
|
||||
prefs := t.shell.hub.TrayPrefs()
|
||||
if _, err := t.shell.hub.SetTrayPrefs(prefs.Icon, !prefs.CloseToTray); err != nil {
|
||||
slog.Warn("studio: close behaviour", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// showBackground paints the checkbox. Called from the hub's apply, so the menu
|
||||
// agrees with the setting however it was changed.
|
||||
func (t *tray) showBackground(on bool) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
item := t.stayOpen
|
||||
t.mu.Unlock()
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
if on {
|
||||
item.Check()
|
||||
return
|
||||
}
|
||||
item.Uncheck()
|
||||
}
|
||||
|
||||
// show paints one fold. It is called from whichever goroutine emitted the event
|
||||
// that changed it, so it touches only the icon and its own items.
|
||||
func (t *tray) show(state traystate.State) {
|
||||
t.mu.Lock()
|
||||
t.last = state
|
||||
status := t.status
|
||||
t.mu.Unlock()
|
||||
systray.SetIcon(moodIcon(state.Mood()))
|
||||
line := serve.TrayLine(t.say, state)
|
||||
systray.SetTooltip("Reasonix Studio — " + line)
|
||||
if status != nil {
|
||||
status.SetTitle(line)
|
||||
}
|
||||
}
|
||||
|
||||
// close takes the icon down. The window's own shutdown owns the order: the icon
|
||||
// outlives every pane so the last thing on screen is still telling the truth.
|
||||
func (t *tray) close() {
|
||||
if t == nil || t.stop == nil {
|
||||
return
|
||||
}
|
||||
close(t.done)
|
||||
t.stop()
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
// tray_icon.go — the status icon: the composer's R, in one of three colours.
|
||||
package main
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/traystate"
|
||||
)
|
||||
|
||||
// One hue per mood and nothing else: the window's palette already gives each of
|
||||
// the three a single meaning — accent is "needs you", net is "running on its
|
||||
// own", ghost is neutral. The lightness belongs to neither theme, because a
|
||||
// notification area is a background this process cannot ask about: these clear
|
||||
// 3:1 against both white and near-black.
|
||||
var moodInk = map[traystate.Mood]color.NRGBA{
|
||||
traystate.MoodIdle: {R: 0x82, G: 0x7C, B: 0x74, A: 0xFF},
|
||||
traystate.MoodWorking: {R: 0x50, G: 0x79, B: 0xD3, A: 0xFF},
|
||||
traystate.MoodAttention: {R: 0xAD, G: 0x72, B: 0x27, A: 0xFF},
|
||||
}
|
||||
|
||||
// iconSize is drawn well above the 16px the trays ask for: every platform
|
||||
// downscales, and none of them upscale kindly. markMargin is what stays clear
|
||||
// on the tight side, since a menu bar scales the image to its own height.
|
||||
const (
|
||||
iconSize = 64
|
||||
markMargin = 6
|
||||
)
|
||||
|
||||
// The mark is the composer's, kept in the 16-unit space RMark.tsx draws it in so
|
||||
// the two can be read side by side rather than compared by eye.
|
||||
const markStroke = 1.7
|
||||
|
||||
var markStrokes = [...][4]float64{
|
||||
{4.7, 2.9, 4.7, 13.1}, // stem
|
||||
{4.7, 2.9, 8.9, 2.9}, // the bowl's top
|
||||
{8.9, 8.7, 4.7, 8.7}, // and its bottom
|
||||
{9, 8.7, 12.3, 13.1}, // leg
|
||||
}
|
||||
|
||||
// markBowl is the SVG's `a 2.9 2.9 0 0 1 0 5.8` resolved: the endpoints are one
|
||||
// diameter apart, so the arc is the half circle between them, and sweep 1 puts
|
||||
// that half to the right of the stem.
|
||||
var markBowl = struct{ cx, cy, r float64 }{8.9, 5.8, 2.9}
|
||||
|
||||
var (
|
||||
iconOnce sync.Once
|
||||
iconBytes map[traystate.Mood][]byte
|
||||
)
|
||||
|
||||
// moodIcon returns the platform's icon bytes for one mood, drawn once.
|
||||
func moodIcon(mood traystate.Mood) []byte {
|
||||
iconOnce.Do(func() {
|
||||
iconBytes = map[traystate.Mood][]byte{}
|
||||
for _, m := range []traystate.Mood{traystate.MoodIdle, traystate.MoodWorking, traystate.MoodAttention} {
|
||||
iconBytes[m] = encodeIcon(drawMark(m))
|
||||
}
|
||||
})
|
||||
return iconBytes[mood]
|
||||
}
|
||||
|
||||
// drawMark paints the R at one mood's colour.
|
||||
func drawMark(mood traystate.Mood) image.Image {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
ink := moodInk[mood]
|
||||
scale, offX, offY := markFit()
|
||||
half := markStroke / 2 * scale
|
||||
for y := range iconSize {
|
||||
for x := range iconSize {
|
||||
away := markDistance((float64(x)+0.5-offX)/scale, (float64(y)+0.5-offY)/scale) * scale
|
||||
// One pixel of falloff across the edge is all the antialiasing a
|
||||
// shape of four strokes and an arc needs.
|
||||
cover := math.Max(0, math.Min(1, half-away+0.5))
|
||||
if cover == 0 {
|
||||
continue
|
||||
}
|
||||
lit := ink
|
||||
lit.A = uint8(cover * 255)
|
||||
img.SetNRGBA(x, y, lit)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// markFit maps glyph units onto icon pixels: the ink centred, scaled until the
|
||||
// tight side reaches the margin, aspect kept.
|
||||
func markFit() (scale, offX, offY float64) {
|
||||
minX, minY, maxX, maxY := markInk()
|
||||
w, h := maxX-minX, maxY-minY
|
||||
scale = math.Min((iconSize-2*markMargin)/w, (iconSize-2*markMargin)/h)
|
||||
return scale, (iconSize-w*scale)/2 - minX*scale, (iconSize-h*scale)/2 - minY*scale
|
||||
}
|
||||
|
||||
// markInk is the box the painted glyph occupies — the skeleton grown by half a
|
||||
// stroke, which is where the round caps end.
|
||||
func markInk() (minX, minY, maxX, maxY float64) {
|
||||
half := markStroke / 2
|
||||
minX, minY = math.Inf(1), math.Inf(1)
|
||||
maxX, maxY = math.Inf(-1), math.Inf(-1)
|
||||
grow := func(x, y float64) {
|
||||
minX, minY = math.Min(minX, x-half), math.Min(minY, y-half)
|
||||
maxX, maxY = math.Max(maxX, x+half), math.Max(maxY, y+half)
|
||||
}
|
||||
for _, s := range markStrokes {
|
||||
grow(s[0], s[1])
|
||||
grow(s[2], s[3])
|
||||
}
|
||||
grow(markBowl.cx+markBowl.r, markBowl.cy)
|
||||
return minX, minY, maxX, maxY
|
||||
}
|
||||
|
||||
// markDistance measures a point in glyph units against the skeleton. Reading a
|
||||
// stroke as a capsule is what gives the round caps and round joins the SVG asks
|
||||
// for without drawing either.
|
||||
func markDistance(x, y float64) float64 {
|
||||
best := bowlDistance(x, y)
|
||||
for _, s := range markStrokes {
|
||||
best = math.Min(best, segmentDistance(x, y, s[0], s[1], s[2], s[3]))
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func segmentDistance(x, y, ax, ay, bx, by float64) float64 {
|
||||
dx, dy := bx-ax, by-ay
|
||||
t := 0.0
|
||||
if span := dx*dx + dy*dy; span > 0 {
|
||||
t = math.Max(0, math.Min(1, ((x-ax)*dx+(y-ay)*dy)/span))
|
||||
}
|
||||
return math.Hypot(x-(ax+t*dx), y-(ay+t*dy))
|
||||
}
|
||||
|
||||
// bowlDistance answers for the half circle only where that half is. Left of the
|
||||
// centre the nearest ink is one of its two ends, and the strokes that meet the
|
||||
// arc there already own those.
|
||||
func bowlDistance(x, y float64) float64 {
|
||||
if x < markBowl.cx {
|
||||
return math.Inf(1)
|
||||
}
|
||||
return math.Abs(math.Hypot(x-markBowl.cx, y-markBowl.cy) - markBowl.r)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
// macOS and the freedesktop status hosts both take a PNG.
|
||||
func encodeIcon(img image.Image) []byte {
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, img); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/traystate"
|
||||
)
|
||||
|
||||
// The tray's R and the composer's are one drawing in two rasterizers, and only
|
||||
// the SVG can be edited by eye. These are the numbers tray_icon.go's strokes
|
||||
// were derived from, so a move over there fails here and asks for the same
|
||||
// move — rather than leaving the icon quietly drawing the previous logo.
|
||||
func TestTrayMarkTracksTheComposersR(t *testing.T) {
|
||||
src, err := os.ReadFile(filepath.Join("..", "frontend-next", "src", "ui", "RMark.tsx"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"M4.7 2.9V13.1",
|
||||
"M4.7 2.9h4.2a2.9 2.9 0 0 1 0 5.8H4.7",
|
||||
"M9 8.7l3.3 4.4",
|
||||
}
|
||||
var got []string
|
||||
for _, m := range regexp.MustCompile(`d="([^"]+)"`).FindAllStringSubmatch(string(src), -1) {
|
||||
got = append(got, m[1])
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("RMark.tsx draws %d paths, tray_icon.go was derived from %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("path %d is now %q, was %q — re-derive markStrokes/markBowl from it", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same for the weight: the stroke is a CSS declaration on one side and a
|
||||
// constant on the other.
|
||||
func TestTrayMarkStrokeMatchesTheComposers(t *testing.T) {
|
||||
src, err := os.ReadFile(filepath.Join("..", "frontend-next", "src", "styles", "app.css"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := regexp.MustCompile(`\.rmark path \{[^}]*stroke-width:\s*([0-9.]+)`).FindStringSubmatch(string(src))
|
||||
if m == nil {
|
||||
t.Fatal("app.css no longer gives .rmark path a stroke-width; this test cannot see the weight")
|
||||
}
|
||||
width, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if width != markStroke {
|
||||
t.Errorf("composer draws the R at %v, tray at %v", width, markStroke)
|
||||
}
|
||||
}
|
||||
|
||||
// A mood the icon renders the same as another is a status nobody can read, and
|
||||
// the icon is the only surface a hidden window has.
|
||||
func TestEveryMoodRendersItsOwnIcon(t *testing.T) {
|
||||
seen := map[string]traystate.Mood{}
|
||||
for _, mood := range []traystate.Mood{traystate.MoodIdle, traystate.MoodWorking, traystate.MoodAttention} {
|
||||
encoded := moodIcon(mood)
|
||||
if len(encoded) == 0 {
|
||||
t.Fatalf("mood %v encodes to nothing", mood)
|
||||
}
|
||||
if other, dup := seen[string(encoded)]; dup {
|
||||
t.Errorf("moods %v and %v render identically", other, mood)
|
||||
}
|
||||
seen[string(encoded)] = mood
|
||||
}
|
||||
}
|
||||
|
||||
// A menu bar scales the image to its own height, so ink at the edge is ink that
|
||||
// gets clipped; a glyph that does not reach the margin is one nobody can make
|
||||
// out at 16px.
|
||||
func TestTrayMarkFillsTheIconWithoutTouchingTheEdge(t *testing.T) {
|
||||
ink := alphaBounds(drawMark(traystate.MoodIdle))
|
||||
if ink.Min.X < markMargin-1 || ink.Min.Y < markMargin-1 ||
|
||||
ink.Max.X > iconSize-markMargin+1 || ink.Max.Y > iconSize-markMargin+1 {
|
||||
t.Errorf("ink %v leaves the %dpx margin of a %dpx icon", ink, markMargin, iconSize)
|
||||
}
|
||||
if height := ink.Dy(); height < iconSize-2*markMargin-1 {
|
||||
t.Errorf("ink is %dpx tall in a %dpx icon; the margin is %d", height, iconSize, markMargin)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
// Windows reads the notification area's icon as an .ico. Since Vista an entry
|
||||
// may hold a PNG verbatim, so the container is a 22-byte header around the
|
||||
// bytes we already have rather than a second encoder.
|
||||
func encodeIcon(img image.Image) []byte {
|
||||
var body bytes.Buffer
|
||||
if err := png.Encode(&body, img); err != nil {
|
||||
return nil
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
var out bytes.Buffer
|
||||
// ICONDIR: reserved, type 1 (icon), one image.
|
||||
_ = binary.Write(&out, binary.LittleEndian, [3]uint16{0, 1, 1})
|
||||
// ICONDIRENTRY: 0 in a size byte means 256, which is why a 64px mark fits.
|
||||
out.Write([]byte{byte(bounds.Dx()), byte(bounds.Dy()), 0, 0})
|
||||
_ = binary.Write(&out, binary.LittleEndian, [2]uint16{1, 32})
|
||||
_ = binary.Write(&out, binary.LittleEndian, uint32(body.Len()))
|
||||
_ = binary.Write(&out, binary.LittleEndian, uint32(22))
|
||||
out.Write(body.Bytes())
|
||||
return out.Bytes()
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// tray_prefs.go — what only this window can answer about its own status icon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"reasonix/internal/serve"
|
||||
"reasonix/internal/traystate"
|
||||
)
|
||||
|
||||
// IconLive reports whether this launch got an icon. systray gives one up once
|
||||
// per process, so a setting turned back on cannot put another there.
|
||||
func (a *App) IconLive() bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.icon != nil
|
||||
}
|
||||
|
||||
// TrayFold is what the panes add up to. Jobs are the hub's to count.
|
||||
func (a *App) TrayFold() traystate.State {
|
||||
if a.tracker == nil {
|
||||
return traystate.State{}
|
||||
}
|
||||
return a.tracker.State()
|
||||
}
|
||||
|
||||
// ApplyTrayPrefs takes effect on this launch: the close button answers the flag
|
||||
// rather than re-reading the file, and the menu's checkbox has to agree with it.
|
||||
func (a *App) ApplyTrayPrefs(prefs serve.TrayPrefs) {
|
||||
a.background.Store(prefs.CloseToTray)
|
||||
a.mu.Lock()
|
||||
icon := a.icon
|
||||
a.mu.Unlock()
|
||||
icon.showBackground(prefs.CloseToTray)
|
||||
}
|
||||
|
||||
// adoptIcon records the icon this launch got, if any.
|
||||
func (a *App) adoptIcon(t *tray) {
|
||||
a.mu.Lock()
|
||||
a.icon = t
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// closeIcon takes it down at shutdown, which is the only time it comes down.
|
||||
func (a *App) closeIcon() {
|
||||
a.mu.Lock()
|
||||
t := a.icon
|
||||
a.icon = nil
|
||||
a.mu.Unlock()
|
||||
t.close()
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
void rxTrayStartOnMain(void);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "fyne.io/systray"
|
||||
|
||||
// systray's nativeStart builds the NSStatusItem inline, and AppKit only lets a
|
||||
// window be instantiated on the main thread — which a Wails lifecycle hook is
|
||||
// not. Every other systray call already hops there itself; this one does not,
|
||||
// so the hop happens here. Async is safe: ready() ends by replaying the last
|
||||
// state, and a setter that lands before the delegate exists is a nil no-op.
|
||||
var trayStart func()
|
||||
|
||||
func runTray(ready, exit func()) (stop func()) {
|
||||
start, end := systray.RunWithExternalLoop(ready, exit)
|
||||
trayStart = start
|
||||
C.rxTrayStartOnMain()
|
||||
return end
|
||||
}
|
||||
|
||||
//export rxTrayStart
|
||||
func rxTrayStart() {
|
||||
if trayStart != nil {
|
||||
trayStart()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import "_cgo_export.h"
|
||||
|
||||
void rxTrayStartOnMain(void) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
rxTrayStart();
|
||||
});
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package main
|
||||
|
||||
import "fyne.io/systray"
|
||||
|
||||
// runTray keeps the documented embedding path here. Giving the icon a thread of
|
||||
// its own — the Windows fix — is not it: on the platforms left here the icon
|
||||
// rides the caller's goroutine. macOS needs the main thread and has its own
|
||||
// file.
|
||||
func runTray(ready, exit func()) (stop func()) {
|
||||
start, end := systray.RunWithExternalLoop(ready, exit)
|
||||
start()
|
||||
return end
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"fyne.io/systray"
|
||||
)
|
||||
|
||||
// runTray gives the icon its own OS thread. GetMessage only ever sees the
|
||||
// messages of the thread that owns the window, and RunWithExternalLoop — which
|
||||
// reads like the embedding path — pumps from a goroutine nobody pinned. The
|
||||
// icon drew, because Shell_NotifyIcon does that, and then answered nothing.
|
||||
func runTray(ready, exit func()) (stop func()) {
|
||||
go func() {
|
||||
runtime.LockOSThread()
|
||||
systray.Run(ready, exit)
|
||||
}()
|
||||
return systray.Quit
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/serve"
|
||||
)
|
||||
|
||||
// What this file owns is when the acknowledgement happens, so both halves of
|
||||
// that are seams: a test has to watch the timing without a real transaction.
|
||||
var (
|
||||
// updateProbation is how long a loaded window must survive before the update
|
||||
// it booted from is committed. A build that paints once and then dies is not
|
||||
// evidence that the replacement works.
|
||||
updateProbation = 2 * time.Second
|
||||
commitUpdateHealth = func(h serve.UpdateHost) error { return h.AcknowledgeLaunchHealth() }
|
||||
)
|
||||
|
||||
// acknowledgeUpdateHealth reports that this launch is working. The updater
|
||||
// performed the swap and cannot judge it; this process can, and only after a
|
||||
// renderer loaded into a window that stayed up. The window supplies the timing
|
||||
// and names nothing: what it retires is the transaction the host read at
|
||||
// startup. Shutting down inside probation leaves the rollback material.
|
||||
func (a *App) acknowledgeUpdateHealth(ctx context.Context) {
|
||||
host := a.updateHost
|
||||
if host == nil {
|
||||
return
|
||||
}
|
||||
// Read where the wait is armed, not inside it: a test restoring these when
|
||||
// it ends would otherwise be writing what a wait it did not outlive reads.
|
||||
probation, commit := updateProbation, commitUpdateHealth
|
||||
go func() {
|
||||
timer := time.NewTimer(probation)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if err := commit(host); err != nil {
|
||||
slog.Warn("studio: commit healthy update", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/update"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/serve"
|
||||
)
|
||||
|
||||
type stubUpdateHost struct{}
|
||||
|
||||
func (stubUpdateHost) AcknowledgeLaunchHealth() error { return nil }
|
||||
|
||||
// The rest of the port. This test is about the health acknowledgement, and a
|
||||
// stub that answers the whole interface keeps it that way.
|
||||
func (stubUpdateHost) StartInstall(update.Install, string) error { return nil }
|
||||
func (stubUpdateHost) InstallProgress() update.Progress { return update.Progress{} }
|
||||
|
||||
func withProbation(t *testing.T, d time.Duration) {
|
||||
t.Helper()
|
||||
original := updateProbation
|
||||
updateProbation = d
|
||||
t.Cleanup(func() { updateProbation = original })
|
||||
}
|
||||
|
||||
func withCommitSeam(t *testing.T) chan struct{} {
|
||||
t.Helper()
|
||||
original := commitUpdateHealth
|
||||
committed := make(chan struct{}, 1)
|
||||
commitUpdateHealth = func(serve.UpdateHost) error {
|
||||
committed <- struct{}{}
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { commitUpdateHealth = original })
|
||||
return committed
|
||||
}
|
||||
|
||||
// The window supplies the timing and nothing else: which transaction this
|
||||
// retires was read by the host at startup, so there is no version here to
|
||||
// assert on any more, and none for a shell to get wrong.
|
||||
func TestReadyWindowCommitsAfterProbation(t *testing.T) {
|
||||
withProbation(t, time.Millisecond)
|
||||
committed := withCommitSeam(t)
|
||||
app := &App{updateHost: stubUpdateHost{}}
|
||||
|
||||
app.acknowledgeUpdateHealth(context.Background())
|
||||
|
||||
select {
|
||||
case <-committed:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("a window that survived probation never committed the update it booted from")
|
||||
}
|
||||
}
|
||||
|
||||
// A build that dies inside probation has shown nothing. Committing there would
|
||||
// retire the rollback material of the update that killed it.
|
||||
func TestWindowLostInsideProbationCommitsNothing(t *testing.T) {
|
||||
withProbation(t, time.Hour)
|
||||
committed := withCommitSeam(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
app := &App{updateHost: stubUpdateHost{}}
|
||||
|
||||
app.acknowledgeUpdateHealth(ctx)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-committed:
|
||||
t.Fatal("a window lost inside probation committed")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// A shell with no capability behind it has nothing to acknowledge through, and
|
||||
// must not invent one. What a launch that booted from no update retires is the
|
||||
// host's question, and it answers it by retiring nothing.
|
||||
func TestAShellWithNoUpdateCapabilityCommitsNothing(t *testing.T) {
|
||||
withProbation(t, time.Millisecond)
|
||||
committed := withCommitSeam(t)
|
||||
|
||||
(&App{}).acknowledgeUpdateHealth(context.Background())
|
||||
|
||||
select {
|
||||
case <-committed:
|
||||
t.Fatal("a shell owning no update capability committed")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package main
|
||||
|
||||
import "reasonix/internal/update"
|
||||
|
||||
// version is stamped by the build (-X main.version=…); an unstamped dev build
|
||||
// says so rather than pretending to be a release.
|
||||
var version = "dev"
|
||||
|
||||
// install is what this shell knows about itself: which build runs and where it
|
||||
// lives. The kernel cannot work either out for a shell that is not the process
|
||||
// asking, so each one states its own.
|
||||
func (a *App) install() update.Install {
|
||||
return update.Install{Version: version, Layout: update.Here(update.StudioLine())}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/mod/modfile"
|
||||
)
|
||||
|
||||
const (
|
||||
webview2ModulePath = "github.com/wailsapp/go-webview2"
|
||||
webview2PatchPath = "./third_party/go-webview2"
|
||||
)
|
||||
|
||||
func TestWebView2PatchWiring(t *testing.T) {
|
||||
modData, err := os.ReadFile(filepath.Join("..", "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mod, err := modfile.Parse("go.mod", modData, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replaced := false
|
||||
for _, directive := range mod.Replace {
|
||||
if directive.Old.Path == webview2ModulePath && directive.New.Path == webview2PatchPath {
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
t.Fatalf("%s must be replaced by %s", webview2ModulePath, webview2PatchPath)
|
||||
}
|
||||
|
||||
patchFile := filepath.Join("..", "third_party", "go-webview2", "pkg", "edge", "chromium.go")
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), patchFile, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
policyEnabled := false
|
||||
policyApplied := false
|
||||
proxyIsolationArgDefined := false
|
||||
proxyIsolationArgApplied := false
|
||||
ast.Inspect(parsed, func(node ast.Node) bool {
|
||||
switch value := node.(type) {
|
||||
case *ast.ValueSpec:
|
||||
if len(value.Names) == 1 && value.Names[0].Name == "shouldDetectMonitorScaleChanges" && len(value.Values) == 1 {
|
||||
ident, ok := value.Values[0].(*ast.Ident)
|
||||
policyEnabled = ok && ident.Name == "true"
|
||||
}
|
||||
if len(value.Names) == 1 && value.Names[0].Name == "reasonixNoProxyServerBrowserArg" && len(value.Values) == 1 {
|
||||
literal, ok := value.Values[0].(*ast.BasicLit)
|
||||
if ok {
|
||||
unquoted, err := strconv.Unquote(literal.Value)
|
||||
proxyIsolationArgDefined = err == nil && unquoted == "--no-proxy-server"
|
||||
}
|
||||
}
|
||||
case *ast.CallExpr:
|
||||
selector, ok := value.Fun.(*ast.SelectorExpr)
|
||||
if !ok || selector.Sel.Name != "PutShouldDetectMonitorScaleChanges" || len(value.Args) != 1 {
|
||||
break
|
||||
}
|
||||
ident, ok := value.Args[0].(*ast.Ident)
|
||||
policyApplied = ok && ident.Name == "shouldDetectMonitorScaleChanges"
|
||||
case *ast.CompositeLit:
|
||||
ident, ok := value.Type.(*ast.Ident)
|
||||
if !ok || ident.Name != "Chromium" {
|
||||
break
|
||||
}
|
||||
for _, element := range value.Elts {
|
||||
entry, ok := element.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, ok := entry.Key.(*ast.Ident)
|
||||
if !ok || key.Name != "AdditionalBrowserArgs" {
|
||||
continue
|
||||
}
|
||||
args, ok := entry.Value.(*ast.CompositeLit)
|
||||
if !ok || len(args.Elts) != 1 {
|
||||
continue
|
||||
}
|
||||
arg, ok := args.Elts[0].(*ast.Ident)
|
||||
proxyIsolationArgApplied = ok && arg.Name == "reasonixNoProxyServerBrowserArg"
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !policyEnabled || !policyApplied {
|
||||
t.Fatal("patched WebView2 must enable and apply automatic monitor-scale detection")
|
||||
}
|
||||
if !proxyIsolationArgDefined || !proxyIsolationArgApplied {
|
||||
t.Fatal("patched WebView2 must pass --no-proxy-server to the browser process")
|
||||
}
|
||||
|
||||
recoveryPolicyDefined := false
|
||||
recoveryPolicyApplied := false
|
||||
recoveryCompletionApplied := false
|
||||
recoveryNavigationBound := false
|
||||
nativeReloadApplied := false
|
||||
nonFatalRecoveryErrors := false
|
||||
fatalRecoveryErrors := false
|
||||
diagnosticCollected := false
|
||||
diagnosticObserved := false
|
||||
for _, declaration := range parsed.Decls {
|
||||
fn, ok := declaration.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if fn.Name.Name == "beginFailedRendererRecovery" {
|
||||
recoveryPolicyDefined = true
|
||||
}
|
||||
if fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if ident, ok := call.Fun.(*ast.Ident); ok {
|
||||
switch ident.Name {
|
||||
case "collectProcessFailedDiagnostic":
|
||||
diagnosticCollected = true
|
||||
case "notifyProcessFailedObserver":
|
||||
diagnosticObserved = true
|
||||
case "reload":
|
||||
if fn.Name.Name == "handleFailedRendererRecovery" {
|
||||
nativeReloadApplied = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
selector, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch selector.Sel.Name {
|
||||
case "beginFailedRendererRecovery":
|
||||
if fn.Name.Name == "ProcessFailed" || fn.Name.Name == "handleFailedRendererRecovery" {
|
||||
recoveryPolicyApplied = true
|
||||
}
|
||||
case "completeFailedRendererRecovery":
|
||||
if fn.Name.Name == "NavigationCompleted" {
|
||||
recoveryCompletionApplied = true
|
||||
}
|
||||
case "Reload":
|
||||
if fn.Name.Name == "ProcessFailed" || fn.Name.Name == "handleFailedRendererRecovery" {
|
||||
nativeReloadApplied = true
|
||||
}
|
||||
case "bindNavigation":
|
||||
if fn.Name.Name == "NavigationStarting" {
|
||||
recoveryNavigationBound = true
|
||||
}
|
||||
case "nonFatalErrorCallback":
|
||||
if fn.Name.Name == "ProcessFailed" || fn.Name.Name == "handleFailedRendererRecovery" || fn.Name.Name == "completeFailedRendererRecovery" {
|
||||
nonFatalRecoveryErrors = true
|
||||
}
|
||||
case "errorCallback":
|
||||
if fn.Name.Name == "ProcessFailed" || fn.Name.Name == "completeFailedRendererRecovery" {
|
||||
fatalRecoveryErrors = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if !recoveryPolicyDefined || !recoveryPolicyApplied || !recoveryCompletionApplied || !recoveryNavigationBound || !nativeReloadApplied {
|
||||
t.Fatal("patched WebView2 must throttle and natively reload failed main renderers")
|
||||
}
|
||||
if !nonFatalRecoveryErrors || fatalRecoveryErrors {
|
||||
t.Fatal("renderer recovery failures must be reported without exiting the desktop")
|
||||
}
|
||||
if !diagnosticCollected || !diagnosticObserved {
|
||||
t.Fatal("patched WebView2 must collect and synchronously publish native process diagnostics")
|
||||
}
|
||||
|
||||
argsFile := filepath.Join("..", "third_party", "go-webview2", "pkg", "edge", "ICoreWebView2ProcessFailedEventArgs.go")
|
||||
argsData, err := os.ReadFile(argsFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"{4DAB9422-46FA-4C3E-A5D2-41D2071D3680}",
|
||||
"{AB667428-094D-5FD1-B480-8B4C0FDBDF2F}",
|
||||
"GetICoreWebView2ProcessFailedEventArgs2",
|
||||
"GetICoreWebView2ProcessFailedEventArgs3",
|
||||
} {
|
||||
if !strings.Contains(string(argsData), expected) {
|
||||
t.Fatalf("patched WebView2 process-failed args must include %s", expected)
|
||||
}
|
||||
}
|
||||
for _, requiredFile := range []string{
|
||||
"ICoreWebView2NavigationStartingEventArgs.go",
|
||||
"ICoreWebView2NavigationStartingEventHandler.go",
|
||||
"ICoreWebView2ProcessFailedEventArgs2.go",
|
||||
"ICoreWebView2ProcessFailedEventArgs3.go",
|
||||
"process_failed_diagnostics.go",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join("..", "third_party", "go-webview2", "pkg", "edge", requiredFile)); err != nil {
|
||||
t.Fatalf("required native diagnostics patch %s is missing: %v", requiredFile, err)
|
||||
}
|
||||
}
|
||||
diagnosticsData, err := os.ReadFile(filepath.Join("..", "third_party", "go-webview2", "pkg", "edge", "process_failed_diagnostics.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(diagnosticsData), "COREWEBVIEW2_PROCESS_FAILED_KIND_UNKNOWN_PROCESS_EXITED") {
|
||||
t.Fatal("a failed process-kind getter must not default to a fatal browser exit")
|
||||
}
|
||||
navigationArgsData, err := os.ReadFile(filepath.Join("..", "third_party", "go-webview2", "pkg", "edge", "ICoreWebView2NavigationCompletedEventArgs.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"GetIsSuccess", "GetNavigationID"} {
|
||||
if !strings.Contains(string(navigationArgsData), expected) {
|
||||
t.Fatalf("renderer recovery navigation completion must include %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
coreFile := filepath.Join("..", "third_party", "go-webview2", "pkg", "edge", "corewebview2.go")
|
||||
coreParsed, err := parser.ParseFile(token.NewFileSet(), coreFile, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadWrapperDefined := false
|
||||
reloadVTableApplied := false
|
||||
for _, declaration := range coreParsed.Decls {
|
||||
fn, ok := declaration.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "Reload" || fn.Recv == nil || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
reloadWrapperDefined = true
|
||||
ast.Inspect(fn.Body, func(node ast.Node) bool {
|
||||
selector, ok := node.(*ast.SelectorExpr)
|
||||
if ok && selector.Sel.Name == "Reload" {
|
||||
reloadVTableApplied = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if !reloadWrapperDefined || !reloadVTableApplied {
|
||||
t.Fatal("patched WebView2 must expose ICoreWebView2.Reload through the native COM vtable")
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package main
|
||||
|
||||
import "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
// Frameless on Windows takes the native minimise/maximise/close with it, so the
|
||||
// chrome has to draw its own and these are what they call. macOS keeps its
|
||||
// lights and never renders them; Linux keeps its whole frame.
|
||||
|
||||
func (a *App) MinimiseWindow() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
runtime.WindowMinimise(a.ctx)
|
||||
}
|
||||
|
||||
func (a *App) ToggleMaximiseWindow() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
runtime.WindowToggleMaximise(a.ctx)
|
||||
}
|
||||
|
||||
// IsWindowMaximised backs the restore/maximise glyph swap.
|
||||
func (a *App) IsWindowMaximised() bool {
|
||||
if a.ctx == nil {
|
||||
return false
|
||||
}
|
||||
return runtime.WindowIsMaximised(a.ctx)
|
||||
}
|
||||
|
||||
func (a *App) CloseWindow() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
runtime.Quit(a.ctx)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
//go:build desktop
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// How much of the screen a fresh window may take. Proportional rather than a
|
||||
// fixed margin: 64px of air is cramped on a 4K panel and generous on a laptop,
|
||||
// while "most of it, not all of it" reads the same at every size.
|
||||
const screenShare = 90
|
||||
|
||||
// fitted returns the size to open at: the preference is a ceiling, not a
|
||||
// constant, so the window is whichever is smaller. A screen reporting nothing
|
||||
// (some drivers do before the first paint) leaves the preference alone rather
|
||||
// than collapsing the window to a share of zero.
|
||||
func fitted(maxW, maxH, screenW, screenH int) (int, int) {
|
||||
if screenW > 0 {
|
||||
maxW = min(maxW, screenW*screenShare/100)
|
||||
}
|
||||
if screenH > 0 {
|
||||
maxH = min(maxH, screenH*screenShare/100)
|
||||
}
|
||||
return maxW, maxH
|
||||
}
|
||||
|
||||
// fitWindow sizes and centres the window before it is shown. Sizes are logical
|
||||
// pixels, which is the trap: 1080p at Windows' common 125% scaling lays out in
|
||||
// 1536x864, so the 1440x900 default centres with its top edge at -18 — exactly
|
||||
// the title bar, and with it the way to move the window. Centring is
|
||||
// unconditional because Wails does not promise it on Windows.
|
||||
func fitWindow(ctx context.Context) {
|
||||
// Whatever happens below, the window becomes visible. A measurement that
|
||||
// fails must cost a badly sized window, never an invisible one.
|
||||
defer runtime.WindowShow(ctx)
|
||||
|
||||
screens, err := runtime.ScreenGetAll(ctx)
|
||||
if err != nil || len(screens) == 0 {
|
||||
slog.Debug("studio: no screen geometry, keeping default window size", "err", err)
|
||||
return
|
||||
}
|
||||
screen := screens[0]
|
||||
for _, s := range screens {
|
||||
if s.IsCurrent {
|
||||
screen = s
|
||||
break
|
||||
}
|
||||
}
|
||||
// Logical pixels, because that is the space window sizes are set in.
|
||||
sw, sh := screen.Size.Width, screen.Size.Height
|
||||
if sw == 0 || sh == 0 {
|
||||
sw, sh = screen.Width, screen.Height
|
||||
}
|
||||
w, h := runtime.WindowGetSize(ctx)
|
||||
fw, fh := fitted(w, h, sw, sh)
|
||||
if fw != w || fh != h {
|
||||
slog.Info("studio: window did not fit the screen", "screen", []int{sw, sh}, "from", []int{w, h}, "to", []int{fw, fh})
|
||||
runtime.WindowSetSize(ctx, fw, fh)
|
||||
}
|
||||
runtime.WindowCenter(ctx)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build !desktop
|
||||
|
||||
package main
|
||||
|
||||
import "context"
|
||||
|
||||
// fitWindow needs the Wails runtime that only the desktop tag links in. The
|
||||
// no-op keeps the package buildable and vettable without it: a Wails app no
|
||||
// tool can analyse is one where a type error waits until release day.
|
||||
func fitWindow(context.Context) {}
|
||||
@@ -1,37 +0,0 @@
|
||||
//go:build desktop
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The failure this guards is not "the window is a bit big": on Windows the app
|
||||
// draws its own title bar, so a window taller than the screen puts that bar —
|
||||
// and the only region that can drag the window — above the top edge.
|
||||
func TestFittedKeepsTheWindowOnScreen(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
w, h, sw, sh int
|
||||
wantW, wantH int
|
||||
}{
|
||||
// 报障的那台:物理 1920x1080,Windows 125% 缩放后可布局的是 1536x864
|
||||
{"1080p 屏在 125% 缩放下只剩 1536x864", 1440, 900, 1536, 864, 1382, 777},
|
||||
{"同一块屏 150% 缩放只剩 1280x720", 1440, 900, 1280, 720, 1152, 648},
|
||||
{"1366x768 的笔记本", 1440, 900, 1366, 768, 1229, 691},
|
||||
{"大屏上按上限来,不铺满", 1440, 900, 2560, 1440, 1440, 900},
|
||||
{"只有高度放不下", 1200, 900, 1920, 800, 1200, 720},
|
||||
{"屏幕尺寸报不出来时不动", 1440, 900, 0, 0, 1440, 900},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w, h := fitted(tc.w, tc.h, tc.sw, tc.sh)
|
||||
if w != tc.wantW || h != tc.wantH {
|
||||
t.Errorf("fitted(%d,%d on %dx%d) = %d,%d; want %d,%d", tc.w, tc.h, tc.sw, tc.sh, w, h, tc.wantW, tc.wantH)
|
||||
}
|
||||
if tc.sw > 0 && w > tc.sw {
|
||||
t.Errorf("still wider than the screen: %d > %d", w, tc.sw)
|
||||
}
|
||||
if tc.sh > 0 && h > tc.sh {
|
||||
t.Errorf("still taller than the screen: %d > %d", h, tc.sh)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 John Chadwick
|
||||
Some portions Copyright (c) 2017 Serge Zaitsev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,22 +0,0 @@
|
||||
# Reasonix WebView2 Patch
|
||||
|
||||
This directory contains the Windows packages used by Wails v2 from
|
||||
`github.com/wailsapp/go-webview2` v1.0.23 (commit
|
||||
`e08e9397f04ad0b1050d7e5bdc8e27faddc87987`). The original MIT license is in
|
||||
`LICENSE`.
|
||||
|
||||
Reasonix carries one behavioral patch in `pkg/edge/chromium.go`: WebView2
|
||||
monitor-scale detection remains enabled while raw-pixel bounds mode is active.
|
||||
Without it, a frameless window minimized on a mixed-DPI Windows desktop can
|
||||
adopt another monitor's rasterization scale and restore with stale WebView
|
||||
bounds.
|
||||
|
||||
The diagnosis and upstream implementation are documented in:
|
||||
|
||||
- https://github.com/esengine/DeepSeek-Reasonix/issues/5862
|
||||
- https://github.com/wailsapp/wails/issues/5544
|
||||
- https://github.com/wailsapp/wails/pull/5734
|
||||
|
||||
Remove this replacement when Wails v2 publishes an equivalent fix. Until then,
|
||||
keep the copied packages aligned with the version required by `desktop/go.mod`
|
||||
and retain `TestWebView2OwnsMonitorScaleDetection`.
|
||||
@@ -1,15 +0,0 @@
|
||||
module github.com/wailsapp/go-webview2
|
||||
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e
|
||||
github.com/stretchr/testify v1.9.0
|
||||
golang.org/x/sys v0.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,16 +0,0 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,195 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package w32
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
ole32 = windows.NewLazySystemDLL("ole32")
|
||||
Ole32CoInitializeEx = ole32.NewProc("CoInitializeEx")
|
||||
|
||||
kernel32 = windows.NewLazySystemDLL("kernel32")
|
||||
Kernel32GetCurrentThreadID = kernel32.NewProc("GetCurrentThreadId")
|
||||
|
||||
shlwapi = windows.NewLazySystemDLL("shlwapi")
|
||||
shlwapiSHCreateMemStream = shlwapi.NewProc("SHCreateMemStream")
|
||||
|
||||
user32 = windows.NewLazySystemDLL("user32")
|
||||
User32LoadImageW = user32.NewProc("LoadImageW")
|
||||
User32GetSystemMetrics = user32.NewProc("GetSystemMetrics")
|
||||
User32RegisterClassExW = user32.NewProc("RegisterClassExW")
|
||||
User32CreateWindowExW = user32.NewProc("CreateWindowExW")
|
||||
User32DestroyWindow = user32.NewProc("DestroyWindow")
|
||||
User32ShowWindow = user32.NewProc("ShowWindow")
|
||||
User32UpdateWindow = user32.NewProc("UpdateWindow")
|
||||
User32SetFocus = user32.NewProc("SetFocus")
|
||||
User32GetMessageW = user32.NewProc("GetMessageW")
|
||||
User32TranslateMessage = user32.NewProc("TranslateMessage")
|
||||
User32DispatchMessageW = user32.NewProc("DispatchMessageW")
|
||||
User32DefWindowProcW = user32.NewProc("DefWindowProcW")
|
||||
User32GetClientRect = user32.NewProc("GetClientRect")
|
||||
User32PostQuitMessage = user32.NewProc("PostQuitMessage")
|
||||
User32SetWindowTextW = user32.NewProc("SetWindowTextW")
|
||||
User32PostThreadMessageW = user32.NewProc("PostThreadMessageW")
|
||||
User32GetWindowLongPtrW = user32.NewProc("GetWindowLongPtrW")
|
||||
User32SetWindowLongPtrW = user32.NewProc("SetWindowLongPtrW")
|
||||
User32AdjustWindowRect = user32.NewProc("AdjustWindowRect")
|
||||
User32SetWindowPos = user32.NewProc("SetWindowPos")
|
||||
)
|
||||
|
||||
const (
|
||||
SystemMetricsCxIcon = 11
|
||||
SystemMetricsCyIcon = 12
|
||||
)
|
||||
|
||||
const (
|
||||
COINIT_APARTMENTTHREADED = 0x2
|
||||
COINIT_MULTITHREADED = 0x0
|
||||
COINIT_DISABLE_OLE1DDE = 0x4
|
||||
COINIT_SPEED_OVER_MEMORY = 0x8
|
||||
)
|
||||
|
||||
const (
|
||||
SWShow = 5
|
||||
)
|
||||
|
||||
const (
|
||||
SWPNoZOrder = 0x0004
|
||||
SWPNoActivate = 0x0010
|
||||
SWPNoMove = 0x0002
|
||||
SWPFrameChanged = 0x0020
|
||||
)
|
||||
|
||||
const (
|
||||
WMDestroy = 0x0002
|
||||
WMMove = 0x0003
|
||||
WMSize = 0x0005
|
||||
WMClose = 0x0010
|
||||
WMQuit = 0x0012
|
||||
WMGetMinMaxInfo = 0x0024
|
||||
WMNCLButtonDown = 0x00A1
|
||||
WMMoving = 0x0216
|
||||
WMApp = 0x8000
|
||||
)
|
||||
|
||||
const (
|
||||
GWLStyle = -16
|
||||
)
|
||||
|
||||
const (
|
||||
WSOverlapped = 0x00000000
|
||||
WSMaximizeBox = 0x00020000
|
||||
WSThickFrame = 0x00040000
|
||||
WSCaption = 0x00C00000
|
||||
WSSysMenu = 0x00080000
|
||||
WSMinimizeBox = 0x00020000
|
||||
WSOverlappedWindow = (WSOverlapped | WSCaption | WSSysMenu | WSThickFrame | WSMinimizeBox | WSMaximizeBox)
|
||||
)
|
||||
|
||||
type WndClassExW struct {
|
||||
CbSize uint32
|
||||
Style uint32
|
||||
LpfnWndProc uintptr
|
||||
CnClsExtra int32
|
||||
CbWndExtra int32
|
||||
HInstance windows.Handle
|
||||
HIcon windows.Handle
|
||||
HCursor windows.Handle
|
||||
HbrBackground windows.Handle
|
||||
LpszMenuName *uint16
|
||||
LpszClassName *uint16
|
||||
HIconSm windows.Handle
|
||||
}
|
||||
|
||||
type Rect struct {
|
||||
Left int32
|
||||
Top int32
|
||||
Right int32
|
||||
Bottom int32
|
||||
}
|
||||
|
||||
type MinMaxInfo struct {
|
||||
PtReserved Point
|
||||
PtMaxSize Point
|
||||
PtMaxPosition Point
|
||||
PtMinTrackSize Point
|
||||
PtMaxTrackSize Point
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
X, Y int32
|
||||
}
|
||||
|
||||
type Msg struct {
|
||||
Hwnd syscall.Handle
|
||||
Message uint32
|
||||
WParam uintptr
|
||||
LParam uintptr
|
||||
Time uint32
|
||||
Pt Point
|
||||
LPrivate uint32
|
||||
}
|
||||
|
||||
func Utf16PtrToString(p *uint16) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
// Find NUL terminator.
|
||||
end := unsafe.Pointer(p)
|
||||
n := 0
|
||||
for *(*uint16)(end) != 0 {
|
||||
end = unsafe.Pointer(uintptr(end) + unsafe.Sizeof(*p))
|
||||
n++
|
||||
}
|
||||
s := (*[(1 << 30) - 1]uint16)(unsafe.Pointer(p))[:n:n]
|
||||
return string(utf16.Decode(s))
|
||||
}
|
||||
|
||||
func SHCreateMemStream(data []byte) (uintptr, error) {
|
||||
ret, _, err := shlwapiSHCreateMemStream.Call(
|
||||
uintptr(unsafe.Pointer(&data[0])),
|
||||
uintptr(len(data)),
|
||||
)
|
||||
if ret == 0 {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
const CW_USEDEFAULT = 0x80000000
|
||||
|
||||
// GetClientRect retrieves the coordinates of a window's client area. The client coordinates specify the upper-left and lower-right corners of the
|
||||
// client area. Because client coordinates are relative to the upper-left corner of a window's client area, the coordinates of the upper-left
|
||||
// corner are (0,0).
|
||||
func GetClientRect(hwnd uintptr) (Rect, error) {
|
||||
var rect Rect
|
||||
ret, _, err := User32GetClientRect.Call(hwnd, uintptr(unsafe.Pointer(&rect)))
|
||||
if ret == 0 {
|
||||
return Rect{}, err
|
||||
}
|
||||
|
||||
return rect, nil
|
||||
}
|
||||
|
||||
// DefWindowProc calls the default window procedure to provide default processing for any window messages that an application does not process.
|
||||
func DefWindowProc(hwnd, msg, wparam, lparam uintptr) uintptr {
|
||||
ret, _, _ := User32DefWindowProcW.Call(hwnd, msg, wparam, lparam)
|
||||
return ret
|
||||
}
|
||||
|
||||
// DestroyWindow destroys the specified window. The function sends WM_DESTROY and WM_NCDESTROY messages to the window
|
||||
// to deactivate it and remove the keyboard focus from it.
|
||||
func DestroyWindow(hwnd uintptr) error {
|
||||
ret, _, err := User32DestroyWindow.Call(hwnd)
|
||||
if ret == 0 {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package combridge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var (
|
||||
comIfcePointersL sync.RWMutex
|
||||
comIfcePointers = map[uintptr]*comObject{} // Map from ComInterfacePointer to the Go ComObject
|
||||
)
|
||||
|
||||
// Resolve the GoInterface of the specified ComInterfacePointer
|
||||
func Resolve[T IUnknown](ifceP uintptr) T {
|
||||
comIfcePointersL.RLock()
|
||||
comObj := comIfcePointers[ifceP]
|
||||
comIfcePointersL.RUnlock()
|
||||
|
||||
var n T
|
||||
if comObj != nil {
|
||||
t := comObj.resolve(ifceP)
|
||||
if t != nil {
|
||||
n = t.(T)
|
||||
}
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// New returns a new ComObject which implements the specified Com Interface, com calls will be redirected
|
||||
// to the specified go interface.
|
||||
func New[T IUnknown](obj T) *ComObject[T] {
|
||||
cObj := new(
|
||||
ifceDef[T]{obj},
|
||||
)
|
||||
return newComObject[T](cObj)
|
||||
}
|
||||
|
||||
// New2 returns a new ComObject which implements the two specified Com Interfaces, com calls will be redirected
|
||||
// to those interfaces accordingly.
|
||||
// This is needed if a ComObject should implement two interfaces that are not descendants of each other,
|
||||
// then you get multiple inheritance.
|
||||
func New2[T IUnknown, T2 IUnknown](obj T, obj2 T2) *ComObject[T] {
|
||||
cObj := new(
|
||||
ifceDef[T]{obj},
|
||||
ifceDef[T2]{obj2},
|
||||
)
|
||||
return newComObject[T](cObj)
|
||||
}
|
||||
|
||||
// new returns a new ComObject which implements multiple specified Com Interfaces, com calls will be redirected
|
||||
// to the specified go interfaces accordingly.
|
||||
// This is needed if a ComObject should implement multiple interfaces that are not descendants of each other,
|
||||
// then you get multiple inheritance.
|
||||
func new(impls ...ifceImpl) *comObject {
|
||||
impls = append([]ifceImpl{ifceDef[IUnknown]{}}, impls...)
|
||||
|
||||
cObj := &comObject{
|
||||
refCount: 1,
|
||||
ifces: map[string]int{},
|
||||
ifcesImpl: make([]comInterfaceDesc, len(impls)),
|
||||
}
|
||||
|
||||
for i, ifceDef := range impls {
|
||||
vtable, err := ifceDef.ifce()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
needsImplement := false
|
||||
for table := vtable; table != nil; table = table.Parent {
|
||||
guid := table.ComGUID
|
||||
if i, found := cObj.ifces[guid]; found {
|
||||
// This Interface is already implemented
|
||||
if guid == iUnknownGUID {
|
||||
// IUnknown is a special interface and never has an user specific implementation
|
||||
} else if cObj.ifcesImpl[i].impl != ifceDef.impl() {
|
||||
panic(fmt.Sprintf("Interface '%s' is already implemented by another object", table.Name))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
needsImplement = true
|
||||
cObj.ifces[guid] = i
|
||||
}
|
||||
|
||||
if !needsImplement {
|
||||
continue
|
||||
}
|
||||
|
||||
ifceP, ifcePSlice := allocUintptrObject(1)
|
||||
ifcePSlice[0] = vtable.ComVTable
|
||||
cObj.ifcesImpl[i] = comInterfaceDesc{ifceP, ifceDef.impl()}
|
||||
}
|
||||
|
||||
comIfcePointersL.Lock()
|
||||
for _, ifceImpl := range cObj.ifcesImpl {
|
||||
comIfcePointers[ifceImpl.ref] = cObj
|
||||
}
|
||||
comIfcePointersL.Unlock()
|
||||
|
||||
return cObj
|
||||
}
|
||||
|
||||
func newComObject[T IUnknown](comObj *comObject) *ComObject[T] {
|
||||
c := &ComObject[T]{obj: comObj}
|
||||
// Make sure to async release since release needs locks and might block the finalizer goroutine for a longer period
|
||||
runtime.SetFinalizer(c, func(obj *ComObject[T]) { obj.close(true) })
|
||||
return c
|
||||
}
|
||||
|
||||
// ComObject describes an exported go instance to be used as a ComObject which implements
|
||||
// the specified Interface.
|
||||
type ComObject[T IUnknown] struct {
|
||||
obj *comObject
|
||||
closed int32
|
||||
}
|
||||
|
||||
// Ref returns the native uintptr that points to the ComObject that is an interface pointer to T.
|
||||
// This can be used in native calls. If the object has been closed this function will panic.
|
||||
func (o *ComObject[T]) Ref() uintptr {
|
||||
if atomic.LoadInt32(&o.closed) != 0 {
|
||||
panic("ComObject has been released")
|
||||
}
|
||||
return o.obj.queryInterface(guidOf[T](), false)
|
||||
}
|
||||
|
||||
// Close releases the native com object from the go side. It will only be destroyed if the ref counter
|
||||
// reaches zero.
|
||||
// After closing `Ref()` will panic.
|
||||
func (o *ComObject[T]) Close() error {
|
||||
o.close(false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// close releases the native com object from the go side. It will only be destroyed if the ref counter
|
||||
// reaches zero.
|
||||
// After closing `Ref()` will panic.
|
||||
func (o *ComObject[T]) close(asyncRelease bool) {
|
||||
if atomic.CompareAndSwapInt32(&o.closed, 0, 1) {
|
||||
runtime.SetFinalizer(o, nil)
|
||||
if asyncRelease {
|
||||
go o.obj.release()
|
||||
} else {
|
||||
o.obj.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type comInterfaceDesc struct {
|
||||
ref uintptr // The native Com InterfacePointer
|
||||
impl any // The golang target object
|
||||
}
|
||||
|
||||
type comObject struct {
|
||||
l sync.Mutex
|
||||
|
||||
refCount int32
|
||||
ifces map[string]int // Map of ComInterfaceGUID to Interface Slots
|
||||
ifcesImpl []comInterfaceDesc // Slots with InterfaceDescriptors
|
||||
}
|
||||
|
||||
func (c *comObject) queryInterface(ifceGUID string, withAddRef bool) uintptr {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
if c.refCount <= 0 {
|
||||
panic("call on released com object")
|
||||
}
|
||||
|
||||
i, found := c.ifces[ifceGUID]
|
||||
if !found {
|
||||
return 0
|
||||
}
|
||||
|
||||
if withAddRef {
|
||||
c.refCount++
|
||||
}
|
||||
return c.ifcesImpl[i].ref
|
||||
}
|
||||
|
||||
func (c *comObject) resolve(ifceP uintptr) any {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
if c.refCount <= 0 {
|
||||
panic("call on destroyed com object")
|
||||
}
|
||||
|
||||
for _, ifce := range c.ifcesImpl {
|
||||
if ifce.ref != ifceP {
|
||||
continue
|
||||
}
|
||||
|
||||
return ifce.impl
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *comObject) addRef() int32 {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
if c.refCount <= 0 {
|
||||
panic("call on destroyed com object")
|
||||
}
|
||||
|
||||
c.refCount++
|
||||
return c.refCount
|
||||
}
|
||||
|
||||
func (c *comObject) release() int32 {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
if c.refCount <= 0 {
|
||||
panic("call on destroyed com object")
|
||||
}
|
||||
|
||||
if c.refCount--; c.refCount == 0 {
|
||||
comIfcePointersL.Lock()
|
||||
for _, ref := range c.ifcesImpl {
|
||||
delete(comIfcePointers, ref.ref)
|
||||
}
|
||||
comIfcePointersL.Unlock()
|
||||
|
||||
for _, impl := range c.ifcesImpl {
|
||||
ref := impl.ref
|
||||
if ref == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
globalFree(ref)
|
||||
}
|
||||
}
|
||||
|
||||
return c.refCount
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package combridge
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const iUnknownGUID = "{00000000-0000-0000-C000-000000000046}"
|
||||
|
||||
func init() {
|
||||
registerVTableInternal[IUnknown, IUnknown](
|
||||
iUnknownGUID,
|
||||
true,
|
||||
iUnknownQueryInterface,
|
||||
iUnknownAddRef,
|
||||
iUnknownRelease,
|
||||
)
|
||||
}
|
||||
|
||||
type IUnknown interface{}
|
||||
|
||||
func iUnknownQueryInterface(this uintptr, refiid *windows.GUID, ppvObject *uintptr) uintptr {
|
||||
if refiid == nil || ppvObject == nil {
|
||||
return uintptr(windows.E_INVALIDARG)
|
||||
}
|
||||
|
||||
comIfcePointersL.RLock()
|
||||
obj := comIfcePointers[this]
|
||||
comIfcePointersL.RUnlock()
|
||||
|
||||
ref := obj.queryInterface(refiid.String(), true)
|
||||
if ref != 0 {
|
||||
*ppvObject = ref
|
||||
return windows.NO_ERROR
|
||||
}
|
||||
|
||||
*ppvObject = 0
|
||||
return uintptr(windows.E_NOINTERFACE)
|
||||
}
|
||||
|
||||
func iUnknownAddRef(this uintptr) uintptr {
|
||||
comIfcePointersL.RLock()
|
||||
obj := comIfcePointers[this]
|
||||
comIfcePointersL.RUnlock()
|
||||
|
||||
return uintptr(obj.addRef())
|
||||
}
|
||||
|
||||
func iUnknownRelease(this uintptr) uintptr {
|
||||
comIfcePointersL.RLock()
|
||||
obj := comIfcePointers[this]
|
||||
comIfcePointersL.RUnlock()
|
||||
|
||||
return uintptr(obj.release())
|
||||
}
|
||||