diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f80b696
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,142 @@
+name: CI
+
+# PR-only by design: pushing to a branch (including main via a merged PR)
+# must never rebuild or retest on its own — releases are cut exclusively
+# by v* tags (release.yml), and the repo owner was explicit that a bare
+# push should not light up CI. workflow_dispatch covers manual runs.
+on:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ linux:
+ name: Linux (test + package)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.25.12'
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20.19.2'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install Linux build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev
+
+ - name: Install pinned build tools
+ run: |
+ go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74
+ go install github.com/go-task/task/v3/cmd/task@v3.45.4
+ echo "$HOME/go/bin" >> "$GITHUB_PATH"
+
+ # main.go embeds frontend/dist, so the frontend must exist before any
+ # command that loads the root Go package (test, vet, or bindings).
+ - name: Build frontend
+ working-directory: frontend
+ run: |
+ npm ci
+ npm run build
+
+ - name: Test and vet
+ run: |
+ go test -race ./...
+ go vet ./...
+
+ - name: Build native Linux binary
+ run: task linux:build ARCH=amd64
+
+ - name: Check runtime linking
+ run: |
+ file bin/wireguide
+ if ldd bin/wireguide | grep -q 'not found'; then
+ ldd bin/wireguide
+ exit 1
+ fi
+
+ - name: Build and inspect DEB
+ env:
+ GOARCH: amd64
+ GIT_COMMITTER_NAME: WireGuide CI
+ GIT_COMMITTER_EMAIL: noreply@example.com
+ run: |
+ wails3 tool package -name wireguide -format deb -config ./build/linux/nfpm/nfpm.yaml -out ./bin
+ dpkg-deb --info bin/wireguide.deb
+ dpkg-deb --contents bin/wireguide.deb
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: linux-amd64-ci
+ path: |
+ bin/wireguide
+ bin/wireguide.deb
+ if-no-files-found: error
+ retention-days: 7
+
+ macos:
+ name: macOS (test)
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.25.12'
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20.19.2'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Build frontend
+ working-directory: frontend
+ run: |
+ npm ci
+ npm run build
+
+ - name: Test and vet
+ run: |
+ go test -race ./...
+ go vet ./...
+
+ windows:
+ name: Windows (test)
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.25.12'
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20.19.2'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Build frontend
+ working-directory: frontend
+ shell: pwsh
+ run: |
+ npm ci
+ npm run build
+
+ # No -race on Windows: the race detector needs cgo + a C toolchain,
+ # and the runner's mingw setup is not something we want this suite
+ # to depend on. The Linux job's -race pass covers the shared code.
+ - name: Test and vet
+ shell: pwsh
+ run: |
+ go test ./...
+ go vet ./...
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cf85b2a..8e1cafd 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -34,16 +34,16 @@ jobs:
- uses: actions/setup-go@v5
with:
- go-version: '1.25'
+ go-version: '1.25.12'
- uses: actions/setup-node@v4
with:
- node-version: '20'
+ node-version: '20.19.2'
- name: Install wails3 + task
run: |
- go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- go install github.com/go-task/task/v3/cmd/task@latest
+ go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74
+ go install github.com/go-task/task/v3/cmd/task@v3.45.4
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Build .app bundle (ad-hoc signed)
@@ -87,17 +87,17 @@ jobs:
- uses: actions/setup-go@v5
with:
- go-version: '1.25'
+ go-version: '1.25.12'
- uses: actions/setup-node@v4
with:
- node-version: '20'
+ node-version: '20.19.2'
- name: Install wails3 + task
shell: pwsh
run: |
- go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- go install github.com/go-task/task/v3/cmd/task@latest
+ go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74
+ go install github.com/go-task/task/v3/cmd/task@v3.45.4
# Append, don't overwrite — PATH already has system entries.
"$env:USERPROFILE\go\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
@@ -253,7 +253,7 @@ jobs:
- uses: actions/setup-go@v5
with:
- go-version: '1.25'
+ go-version: '1.25.12'
- name: Sign SHA256SUMS (Ed25519)
# HARD failure when the key is missing or mismatched — every
diff --git a/.gitignore b/.gitignore
index 5ed7ea3..88294fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ bin
frontend/dist
frontend/node_modules
build/linux/appimage/build
+build/linux/wireguide.desktop
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
# Go
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9f02398..b19e662 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -8,14 +8,14 @@ Thanks for your interest in contributing!
- Go 1.25+
- Node.js 20+
-- [Wails v3](https://v3alpha.wails.io/) (`go install github.com/wailsapp/wails/v3/cmd/wails3@latest`)
+- [Wails v3](https://v3alpha.wails.io/) (`go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74`)
- macOS with Apple Silicon (for now)
### Build & Run
```bash
# Install frontend dependencies
-cd frontend && npm install && cd ..
+cd frontend && npm ci && cd ..
# Development mode (hot reload)
task dev
diff --git a/README.ko.md b/README.ko.md
index be0abd5..f315d65 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -65,8 +65,8 @@ brew install --cask wireguide
```bash
brew install go node
-go install github.com/go-task/task/v3/cmd/task@latest
-go install github.com/wailsapp/wails/v3/cmd/wails3@latest
+go install github.com/go-task/task/v3/cmd/task@v3.45.4
+go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74
task build
./bin/wireguide
diff --git a/README.md b/README.md
index 879c0ec..ff38ebd 100644
--- a/README.md
+++ b/README.md
@@ -136,8 +136,8 @@ installer registers the helper service and shortcut.
```bash
brew install go node
-go install github.com/go-task/task/v3/cmd/task@latest
-go install github.com/wailsapp/wails/v3/cmd/wails3@latest
+go install github.com/go-task/task/v3/cmd/task@v3.45.4
+go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74
task build
./bin/wireguide
diff --git a/build/Taskfile.yml b/build/Taskfile.yml
index 1819c53..336bdf0 100644
--- a/build/Taskfile.yml
+++ b/build/Taskfile.yml
@@ -19,7 +19,7 @@ tasks:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- - npm install
+ - npm ci
build:frontend:
label: build:frontend (DEV={{.DEV}})
diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml
index ac92ad1..90ef4af 100644
--- a/build/linux/Taskfile.yml
+++ b/build/linux/Taskfile.yml
@@ -142,6 +142,8 @@ tasks:
cmds:
- task: generate:dotdesktop
- task: generate:deb
+ vars:
+ ARCH: '{{.ARCH | default ARCH}}'
create:rpm:
summary: Creates a rpm package
@@ -150,6 +152,8 @@ tasks:
cmds:
- task: generate:dotdesktop
- task: generate:rpm
+ vars:
+ ARCH: '{{.ARCH | default ARCH}}'
create:aur:
summary: Creates a arch linux packager package
@@ -158,19 +162,27 @@ tasks:
cmds:
- task: generate:dotdesktop
- task: generate:aur
+ vars:
+ ARCH: '{{.ARCH | default ARCH}}'
generate:deb:
summary: Creates a deb package
+ env:
+ GOARCH: '{{.ARCH | default ARCH}}'
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:rpm:
summary: Creates a rpm package
+ env:
+ GOARCH: '{{.ARCH | default ARCH}}'
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:aur:
summary: Creates a arch linux packager package
+ env:
+ GOARCH: '{{.ARCH | default ARCH}}'
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml
index 356e2f2..2832d37 100644
--- a/build/linux/nfpm/nfpm.yaml
+++ b/build/linux/nfpm/nfpm.yaml
@@ -24,20 +24,35 @@ contents:
- src: "./build/linux/wireguide.desktop"
dst: "/usr/share/applications/wireguide.desktop"
-# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
+# Wails v3 alpha.74 uses GTK3 and the WebKitGTK 4.1 API on Linux.
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
+ - libayatana-appindicator3-1
+ - iproute2
+ - nftables
+ - pkexec | policykit-1
+
+# NetworkManager supplies SSID detection and immediate Wi-Fi change events,
+# while resolvconf avoids the guarded /etc/resolv.conf fallback on systems
+# that do not run systemd-resolved. Noto supplies Korean UI glyphs and stable
+# cross-platform text metrics. The core client can still operate without these,
+# so keep them as recommendations rather than hard dependencies.
+recommends:
+ - network-manager
+ - resolvconf
+ - fonts-noto-core
+ - fonts-noto-cjk
# Distribution-specific overrides for different package formats and WebKit versions
overrides:
- # RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
+ # RPM package names used by current Fedora/RHEL-family distributions.
rpm:
depends:
- gtk3
- webkit2gtk4.1
- # Arch Linux packages (WebKit 4.1)
+ # Arch Linux package names.
archlinux:
depends:
- gtk3
diff --git a/docs/linux-test-plan.md b/docs/linux-test-plan.md
new file mode 100644
index 0000000..7bae58a
--- /dev/null
+++ b/docs/linux-test-plan.md
@@ -0,0 +1,84 @@
+# Linux verification plan
+
+Target baseline: Debian 13 / Raspberry Pi OS, ARM64, Wayland (`labwc`) and
+WayVNC. CI baseline: Ubuntu latest, AMD64.
+
+## Automated gate
+
+- Clean frontend install and production build (`npm ci`, `npm run build`).
+- Go unit tests and vet after `frontend/dist` exists.
+- Race tests on Linux AMD64 CI. Raspberry Pi's 39-bit ARM64 VMA layout is not
+ supported by Go's race runtime, so `-race` cannot run on this device.
+- Native Wails production build and `ldd` missing-library check.
+- DEB generation, metadata inspection, desktop-file validation, and simulated
+ APT installation.
+- Repeat the native build on ARM64 hardware before a release.
+
+## GUI and desktop integration
+
+- Launch under X11/Xvfb for a crash smoke test.
+- Launch in a real Wayland session and verify initial window, resizing,
+ minimising, close-to-tray, tray menu actions, theme, scaling, and dialogs.
+- On Raspberry Pi's detached/headless labwc output, also test through XWayland
+ (`GDK_BACKEND=x11`). Native Wayland WebKitGTK rendering is corrupted on that
+ virtual output even with the original dependency set, while XWayland renders
+ correctly. This is a test-host compositor limitation, not a frontend result.
+- On the Pi, verified the real Linux tray click path (not only its menu): native
+ close hides the window without terminating the process, and a left click on
+ the tray icon restores a decorated window.
+- Confirm the PolicyKit prompt appears, cancellation is handled, successful
+ authentication starts the helper, and closing the last GUI causes the idle
+ helper to exit.
+- Verify XDG autostart creation/removal and launch after a fresh login.
+- Install the DEB, check application-menu/icon/tray integration, then purge it
+ and confirm no package-owned files remain.
+
+## Linux-only network behaviour
+
+### Raspberry Pi smoke-test result (2026-08-04)
+
+- Imported and connected a disposable WireGuard tunnel through the real helper.
+- Confirmed the `wg` interface was up with MTU 1420 and the configured IPv4
+ route was installed.
+- Disconnected and deleted the tunnel, then confirmed both the interface and
+ route were removed.
+- Confirmed routine RTNETLINK traffic no longer produces false primary-network
+ changes; reconnect decisions now compare the actual default route snapshot.
+- This local peer intentionally had no server, so handshake, payload, DNS and
+ kill-switch verification still require a real test endpoint.
+
+Run these with a local console or a second management path. A full-tunnel or
+kill-switch defect can sever the SSH/VNC session used to test it.
+
+- Helper Unix socket: directory ownership/mode, peer UID rejection, stale
+ socket recovery, GUI reconnect, helper version replacement.
+- TUN: create/configure/remove the interface and recover after forced helper
+ termination.
+- Routing: IPv4/IPv6 split tunnel, dual-stack full tunnel, endpoint bypass,
+ custom `Table`/`FwMark`, repeated connect/disconnect, and no leaked routes or
+ policy rules.
+- DNS: test systemd-resolved, resolvconf, and plain `/etc/resolv.conf` paths;
+ verify restoration after disconnect, helper crash, and reboot.
+- Firewall: kill switch and DNS leak protection using nftables; verify LAN and
+ endpoint exceptions and cleanup after every failure path.
+- Network changes: NetworkManager Wi-Fi SSID events, gateway MAC matching,
+ Ethernet/Wi-Fi handover, DHCP/default-route change, offline/online recovery.
+- Power: logind suspend/resume reconnect and fallback polling on systems
+ without logind.
+
+## Cross-OS comparison points
+
+| Capability | Linux | macOS | Windows |
+|---|---|---|---|
+| Elevation/helper | PolicyKit + Unix socket/peer UID | launchd helper | UAC + named pipe |
+| Tunnel/routes | TUN + `ip` policy routing | utun + route socket | Wintun/IP Helper APIs |
+| DNS | resolved/resolvconf/resolv.conf | networksetup | Windows IP APIs |
+| Kill switch | nftables | pf | Windows firewall/WFP path |
+| Wi-Fi identity | NetworkManager + `/proc` ARP | CoreWLAN | WLAN/IP Helper APIs |
+| Sleep/network events | logind + netlink | IOKit/SystemConfiguration | Windows notifications |
+| Autostart | XDG desktop entry | LaunchAgent | registry Run key |
+| Desktop shell | GTK/WebKitGTK + AppIndicator | AppKit/WebKit | WebView2/tray |
+
+For every platform-sensitive test, compare the observable contract rather
+than the implementation: identical tunnel status, route/DNS cleanup, reconnect
+timing, error text, persisted settings, and tray/window state.
diff --git a/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js b/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js
deleted file mode 100644
index 1ea1058..0000000
--- a/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js
+++ /dev/null
@@ -1,9 +0,0 @@
-//@ts-check
-// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
-// This file is automatically generated. DO NOT EDIT
-
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-ignore: Unused imports
-import { Create as $Create } from "@wailsio/runtime";
-
-Object.freeze($Create.Events);
diff --git a/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
deleted file mode 100644
index 3dd1807..0000000
--- a/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
-// This file is automatically generated. DO NOT EDIT
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3f6e3a9..7f8d615 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,43 +8,28 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
- "@codemirror/autocomplete": "^6.20.1",
- "@codemirror/commands": "^6.10.3",
- "@codemirror/language": "^6.12.3",
- "@codemirror/lint": "^6.9.5",
- "@codemirror/search": "^6.6.0",
- "@codemirror/state": "^6.6.0",
- "@codemirror/theme-one-dark": "^6.1.3",
- "@codemirror/view": "^6.41.0",
- "@lezer/highlight": "^1.2.3",
- "@wailsio/runtime": "latest",
- "codemirror": "^6.0.2"
+ "@codemirror/autocomplete": "6.20.3",
+ "@codemirror/commands": "6.10.4",
+ "@codemirror/language": "6.12.4",
+ "@codemirror/lint": "6.9.7",
+ "@codemirror/search": "6.7.1",
+ "@codemirror/state": "6.7.1",
+ "@codemirror/theme-one-dark": "6.1.3",
+ "@codemirror/view": "6.43.7",
+ "@lezer/highlight": "1.2.3",
+ "@wailsio/runtime": "3.0.0-alpha.74",
+ "codemirror": "6.0.2"
},
"devDependencies": {
- "@sveltejs/vite-plugin-svelte": "^3.0.1",
- "svelte": "^4.2.8",
- "vite": "^5.0.8"
- }
- },
- "node_modules/@ampproject/remapping": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
- "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
+ "@sveltejs/vite-plugin-svelte": "7.2.0",
+ "svelte": "5.56.8",
+ "vite": "8.2.0"
}
},
"node_modules/@codemirror/autocomplete": {
- "version": "6.20.1",
- "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
- "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
- "license": "MIT",
+ "version": "6.20.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -53,22 +38,20 @@
}
},
"node_modules/@codemirror/commands": {
- "version": "6.10.3",
- "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
- "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
- "license": "MIT",
+ "version": "6.10.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
+ "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"dependencies": {
"@codemirror/language": "^6.0.0",
- "@codemirror/state": "^6.6.0",
+ "@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/language": {
- "version": "6.12.3",
- "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
- "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
- "license": "MIT",
+ "version": "6.12.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
+ "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@@ -79,21 +62,19 @@
}
},
"node_modules/@codemirror/lint": {
- "version": "6.9.5",
- "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
- "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
- "license": "MIT",
+ "version": "6.9.7",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
+ "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"dependencies": {
"@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.35.0",
+ "@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
- "version": "6.6.0",
- "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
- "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
- "license": "MIT",
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
+ "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
@@ -101,10 +82,9 @@
}
},
"node_modules/@codemirror/state": {
- "version": "6.6.0",
- "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
- "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
- "license": "MIT",
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
+ "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@@ -122,425 +102,41 @@
}
},
"node_modules/@codemirror/view": {
- "version": "6.41.0",
- "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
- "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
- "license": "MIT",
+ "version": "6.43.7",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz",
+ "integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==",
"dependencies": {
- "@codemirror/state": "^6.6.0",
+ "@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
- "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
- "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
- "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
- "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
- "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
- "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
- "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
- "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
- "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
- "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
- "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
- "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
- "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
- "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
- "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
- "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
- "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
- "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
- "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
- "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
- "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
- "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
- "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=6.0.0"
}
@@ -549,15 +145,13 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
- "license": "MIT"
+ "dev": true
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
@@ -593,417 +187,295 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
- "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
- "cpu": [
- "arm"
- ],
+ "node_modules/@oxc-project/types": {
+ "version": "0.142.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
+ "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
},
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
- "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
+ "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
- "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz",
+ "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
- "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz",
+ "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
- "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
- "cpu": [
- "arm64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
- "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz",
+ "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
- "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
- "cpu": [
- "arm"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
- "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz",
+ "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
- "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
- "cpu": [
- "arm64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
- "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz",
+ "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
- "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
- "cpu": [
- "loong64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
- "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz",
+ "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==",
"cpu": [
- "loong64"
+ "arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
- "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
- "cpu": [
- "ppc64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
- "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz",
+ "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==",
"cpu": [
"ppc64"
],
"dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
- "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
- "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
- "cpu": [
- "riscv64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
- "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz",
+ "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==",
"cpu": [
"s390x"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
- "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz",
+ "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
- "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz",
+ "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
- "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
- "cpu": [
- "x64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
- "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz",
+ "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"openharmony"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
- "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz",
+ "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
- "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
- "cpu": [
- "ia32"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
- "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz",
+ "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
- "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
- "cpu": [
- "x64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@sveltejs/vite-plugin-svelte": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",
- "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
+ "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==",
"dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
- "debug": "^4.3.4",
- "deepmerge": "^4.3.1",
- "kleur": "^4.1.5",
- "magic-string": "^0.30.10",
- "svelte-hmr": "^0.16.0",
- "vitefu": "^0.2.5"
- },
- "engines": {
- "node": "^18.0.0 || >=20"
- },
"peerDependencies": {
- "svelte": "^4.0.0 || ^5.0.0-next.0",
- "vite": "^5.0.0"
+ "acorn": "^8.9.0"
}
},
- "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
- "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz",
+ "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "debug": "^4.3.4"
+ "deepmerge": "^4.3.1",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.2"
},
"engines": {
- "node": "^18.0.0 || >=20"
+ "node": "^20.19 || ^22.12 || >=24"
},
"peerDependencies": {
- "@sveltejs/vite-plugin-svelte": "^3.0.0",
- "svelte": "^4.0.0 || ^5.0.0-next.0",
- "vite": "^5.0.0"
+ "svelte": "^5.46.4",
+ "vite": "^8.0.0-beta.7 || ^8.0.0"
}
},
"node_modules/@types/estree": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
- "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
- "license": "MIT"
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true
},
"node_modules/@wailsio/runtime": {
- "version": "3.0.0-alpha.79",
- "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.79.tgz",
- "integrity": "sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA==",
- "license": "MIT"
+ "version": "3.0.0-alpha.74",
+ "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.74.tgz",
+ "integrity": "sha512-6N3F6MpLDgLfTRIwgwAzxSrIVtlPICxMYDrs0bz5uUJ58IPCQjcqxWOedoMysFdqVaogi5VmCxXLKRJzI5hW2A=="
},
"node_modules/acorn": {
- "version": "8.16.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
- "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
- "license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
@@ -1012,11 +484,10 @@
}
},
"node_modules/aria-query": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
- "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
- "license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
@@ -1026,23 +497,17 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
- "license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
- "node_modules/code-red": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
- "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.4.15",
- "@types/estree": "^1.0.1",
- "acorn": "^8.10.0",
- "estree-walker": "^3.0.3",
- "periscopic": "^3.1.0"
+ "engines": {
+ "node": ">=6"
}
},
"node_modules/codemirror": {
@@ -1066,95 +531,69 @@
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
- "node_modules/css-tree": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
- "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "mdn-data": "2.0.30",
- "source-map-js": "^1.0.1"
- },
"engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ "node": ">=0.10.0"
}
},
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.9.0",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz",
+ "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==",
+ "dev": true
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true
+ },
+ "node_modules/esrap": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz",
+ "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "ms": "^2.1.3"
+ "@jridgewell/sourcemap-codec": "^1.4.15"
},
- "engines": {
- "node": ">=6.0"
+ "peerDependencies": {
+ "@typescript-eslint/types": "^8.2.0"
},
"peerDependenciesMeta": {
- "supports-color": {
+ "@typescript-eslint/types": {
"optional": true
}
}
},
- "node_modules/deepmerge": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
- "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
- "license": "MIT",
"engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/esbuild": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
- "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
+ "node": ">=12.0.0"
},
- "engines": {
- "node": ">=12"
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
},
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.21.5",
- "@esbuild/android-arm": "0.21.5",
- "@esbuild/android-arm64": "0.21.5",
- "@esbuild/android-x64": "0.21.5",
- "@esbuild/darwin-arm64": "0.21.5",
- "@esbuild/darwin-x64": "0.21.5",
- "@esbuild/freebsd-arm64": "0.21.5",
- "@esbuild/freebsd-x64": "0.21.5",
- "@esbuild/linux-arm": "0.21.5",
- "@esbuild/linux-arm64": "0.21.5",
- "@esbuild/linux-ia32": "0.21.5",
- "@esbuild/linux-loong64": "0.21.5",
- "@esbuild/linux-mips64el": "0.21.5",
- "@esbuild/linux-ppc64": "0.21.5",
- "@esbuild/linux-riscv64": "0.21.5",
- "@esbuild/linux-s390x": "0.21.5",
- "@esbuild/linux-x64": "0.21.5",
- "@esbuild/netbsd-x64": "0.21.5",
- "@esbuild/openbsd-x64": "0.21.5",
- "@esbuild/sunos-x64": "0.21.5",
- "@esbuild/win32-arm64": "0.21.5",
- "@esbuild/win32-ia32": "0.21.5",
- "@esbuild/win32-x64": "0.21.5"
- }
- },
- "node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
}
},
"node_modules/fsevents": {
@@ -1163,7 +602,6 @@
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -1177,56 +615,278 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
}
},
- "node_modules/kleur": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
- "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
- "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
"engines": {
- "node": ">=6"
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
- "dev": true,
- "license": "MIT"
+ "dev": true
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/mdn-data": {
- "version": "2.0.30",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
- "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
- "dev": true,
- "license": "CC0-1.0"
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.17",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
+ "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"dev": true,
"funding": [
{
@@ -1234,7 +894,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -1242,29 +901,41 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/periscopic": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
- "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0",
- "estree-walker": "^3.0.0",
- "is-reference": "^3.0.0"
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "engines": {
+ "node": ">=12.20.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
- "license": "ISC"
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
},
"node_modules/postcss": {
- "version": "8.5.8",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
- "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
@@ -1280,9 +951,8 @@
"url": "https://github.com/sponsors/ai"
}
],
- "license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1290,49 +960,36 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/rollup": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
- "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
+ "node_modules/rolldown": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz",
+ "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@types/estree": "1.0.8"
+ "@oxc-project/types": "=0.142.0",
+ "@rolldown/pluginutils": "^1.0.0"
},
"bin": {
- "rollup": "dist/bin/rollup"
+ "rolldown": "bin/cli.mjs"
},
"engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.60.1",
- "@rollup/rollup-android-arm64": "4.60.1",
- "@rollup/rollup-darwin-arm64": "4.60.1",
- "@rollup/rollup-darwin-x64": "4.60.1",
- "@rollup/rollup-freebsd-arm64": "4.60.1",
- "@rollup/rollup-freebsd-x64": "4.60.1",
- "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
- "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
- "@rollup/rollup-linux-arm64-gnu": "4.60.1",
- "@rollup/rollup-linux-arm64-musl": "4.60.1",
- "@rollup/rollup-linux-loong64-gnu": "4.60.1",
- "@rollup/rollup-linux-loong64-musl": "4.60.1",
- "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
- "@rollup/rollup-linux-ppc64-musl": "4.60.1",
- "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
- "@rollup/rollup-linux-riscv64-musl": "4.60.1",
- "@rollup/rollup-linux-s390x-gnu": "4.60.1",
- "@rollup/rollup-linux-x64-gnu": "4.60.1",
- "@rollup/rollup-linux-x64-musl": "4.60.1",
- "@rollup/rollup-openbsd-x64": "4.60.1",
- "@rollup/rollup-openharmony-arm64": "4.60.1",
- "@rollup/rollup-win32-arm64-msvc": "4.60.1",
- "@rollup/rollup-win32-ia32-msvc": "4.60.1",
- "@rollup/rollup-win32-x64-gnu": "4.60.1",
- "@rollup/rollup-win32-x64-msvc": "4.60.1",
- "fsevents": "~2.3.2"
+ "@rolldown/binding-android-arm64": "1.2.2",
+ "@rolldown/binding-darwin-arm64": "1.2.2",
+ "@rolldown/binding-darwin-x64": "1.2.2",
+ "@rolldown/binding-freebsd-x64": "1.2.2",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.2",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.2",
+ "@rolldown/binding-linux-arm64-musl": "1.2.2",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.2",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.2",
+ "@rolldown/binding-linux-x64-gnu": "1.2.2",
+ "@rolldown/binding-linux-x64-musl": "1.2.2",
+ "@rolldown/binding-openharmony-arm64": "1.2.2",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.2",
+ "@rolldown/binding-win32-x64-msvc": "1.2.2"
}
},
"node_modules/source-map-js": {
@@ -1340,7 +997,6 @@
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
- "license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@@ -1352,62 +1008,65 @@
"license": "MIT"
},
"node_modules/svelte": {
- "version": "4.2.20",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
- "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
+ "version": "5.56.8",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz",
+ "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==",
"dev": true,
- "license": "MIT",
- "peer": true,
"dependencies": {
- "@ampproject/remapping": "^2.2.1",
- "@jridgewell/sourcemap-codec": "^1.4.15",
- "@jridgewell/trace-mapping": "^0.3.18",
- "@types/estree": "^1.0.1",
- "acorn": "^8.9.0",
- "aria-query": "^5.3.0",
- "axobject-query": "^4.0.0",
- "code-red": "^1.0.3",
- "css-tree": "^2.3.1",
- "estree-walker": "^3.0.3",
- "is-reference": "^3.0.1",
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.10",
+ "@types/estree": "^1.0.5",
+ "@types/trusted-types": "^2.0.7",
+ "acorn": "^8.12.1",
+ "aria-query": "5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.8.1",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.12",
+ "is-reference": "^3.0.3",
"locate-character": "^3.0.0",
- "magic-string": "^0.30.4",
- "periscopic": "^3.1.0"
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
},
"engines": {
- "node": ">=16"
+ "node": ">=18"
}
},
- "node_modules/svelte-hmr": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",
- "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
- "license": "ISC",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
"engines": {
- "node": "^12.20 || ^14.13.1 || >= 16"
+ "node": ">=12.0.0"
},
- "peerDependencies": {
- "svelte": "^3.19.0 || ^4.0.0"
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/vite": {
- "version": "5.4.21",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
- "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
"dev": true,
- "license": "MIT",
- "peer": true,
"dependencies": {
- "esbuild": "^0.21.3",
- "postcss": "^8.4.43",
- "rollup": "^4.20.0"
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.23",
+ "rolldown": "~1.2.0",
+ "tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -1416,23 +1075,33 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^18.0.0 || >=20.0.0",
- "less": "*",
- "lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
- "terser": "^5.4.0"
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
- "less": {
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
"optional": true
},
- "lightningcss": {
+ "less": {
"optional": true
},
"sass": {
@@ -1449,17 +1118,22 @@
},
"terser": {
"optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
}
}
},
"node_modules/vitefu": {
- "version": "0.2.5",
- "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
- "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+ "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
"dev": true,
- "license": "MIT",
"peerDependencies": {
- "vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"vite": {
@@ -1472,6 +1146,12 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 1762eb3..249de53 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,21 +10,21 @@
"preview": "vite preview"
},
"dependencies": {
- "@codemirror/autocomplete": "^6.20.1",
- "@codemirror/commands": "^6.10.3",
- "@codemirror/language": "^6.12.3",
- "@codemirror/lint": "^6.9.5",
- "@codemirror/search": "^6.6.0",
- "@codemirror/state": "^6.6.0",
- "@codemirror/theme-one-dark": "^6.1.3",
- "@codemirror/view": "^6.41.0",
- "@lezer/highlight": "^1.2.3",
- "@wailsio/runtime": "latest",
- "codemirror": "^6.0.2"
+ "@codemirror/autocomplete": "6.20.3",
+ "@codemirror/commands": "6.10.4",
+ "@codemirror/language": "6.12.4",
+ "@codemirror/lint": "6.9.7",
+ "@codemirror/search": "6.7.1",
+ "@codemirror/state": "6.7.1",
+ "@codemirror/theme-one-dark": "6.1.3",
+ "@codemirror/view": "6.43.7",
+ "@lezer/highlight": "1.2.3",
+ "@wailsio/runtime": "3.0.0-alpha.74",
+ "codemirror": "6.0.2"
},
"devDependencies": {
- "@sveltejs/vite-plugin-svelte": "^3.0.1",
- "svelte": "^4.2.8",
- "vite": "^5.0.8"
+ "@sveltejs/vite-plugin-svelte": "7.2.0",
+ "svelte": "5.56.8",
+ "vite": "8.2.0"
}
}
diff --git a/frontend/public/style.css b/frontend/public/style.css
index 57a3014..b73acdd 100644
--- a/frontend/public/style.css
+++ b/frontend/public/style.css
@@ -114,9 +114,14 @@
/* ============ Non-color tokens (shared across themes) ============ */
- /* Typography: macOS system font stack — SF Pro Text/Display auto-selected */
+ /* Native UI font stack. Keep each platform's normal system face while
+ * giving Linux an explicit metric-compatible choice and Korean glyphs.
+ * A bare sans-serif fallback resolved to DejaVu Sans on Raspberry Pi OS,
+ * whose button baselines sit visibly high compared with SF/Segoe UI. */
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
- "Helvetica Neue", Helvetica, Arial, sans-serif;
+ "Segoe UI", "Apple SD Gothic Neo", "Malgun Gothic",
+ "Cantarell", "Noto Sans", "Noto Sans CJK KR", "Helvetica Neue", Helvetica,
+ Arial, "Liberation Sans", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono",
Consolas, monospace;
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 11d32a0..a27b3a8 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -920,7 +920,10 @@
@media (prefers-reduced-motion: no-preference) {
.drop-overlay { transition: opacity var(--dur-base) var(--ease-out); }
}
- :global(.file-drop-target-active) > .drop-overlay {
+ /* Descendant (not child >) combinator: Svelte 5 prunes child-combinator
+ * rules whose parent class it can't statically prove, and Wails adds
+ * .file-drop-target-active at runtime — a `>` here ships commented-out. */
+ :global(.file-drop-target-active) .drop-overlay {
opacity: 1;
visibility: visible;
backdrop-filter: blur(10px) saturate(180%);
@@ -1451,107 +1454,4 @@
color: var(--text-primary);
font: var(--text-title-2);
}
- .modal label {
- display: block;
- margin: var(--space-3) 0 var(--space-1);
- font: var(--text-subheadline);
- color: var(--text-secondary);
- }
- .modal input[type="text"] {
- width: 100%;
- height: 24px;
- padding: 0 var(--space-2);
- background: var(--bg-input);
- border: 0.5px solid var(--border);
- border-radius: var(--radius-sm);
- color: var(--text-primary);
- font: var(--text-body);
- box-sizing: border-box;
- outline: none;
- }
- .modal input[type="text"]:focus {
- border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--blue-tint);
- }
- .hint {
- font: var(--text-footnote);
- color: var(--text-secondary);
- margin: 0 0 var(--space-3);
- }
- .btn-file-select {
- width: 100%;
- padding: var(--space-3);
- background: var(--bg-card);
- border: 1px dashed var(--border);
- border-radius: var(--radius-sm);
- color: var(--text-primary);
- font: var(--text-body);
- cursor: pointer;
- margin-bottom: var(--space-2);
- }
- @media (prefers-reduced-motion: no-preference) {
- .btn-file-select {
- transition: background-color var(--dur-fast) var(--ease-out),
- border-color var(--dur-fast) var(--ease-out);
- }
- }
- .btn-file-select:hover {
- background: var(--bg-hover);
- border-color: var(--accent);
- }
- .preview {
- margin: var(--space-3) 0;
- padding: var(--space-3);
- background: var(--editor-bg);
- border: 0.5px solid var(--editor-border);
- border-radius: var(--radius-sm);
- font: 10px/14px var(--font-mono);
- color: var(--text-secondary);
- max-height: 200px;
- overflow-y: auto;
- white-space: pre-wrap;
- }
- .errors {
- margin: var(--space-2) 0;
- padding: var(--space-2) var(--space-3);
- background: var(--error-bg);
- border: 0.5px solid var(--red);
- border-radius: var(--radius-sm);
- }
- .errors p {
- margin: var(--space-1) 0;
- color: var(--error-text);
- font: var(--text-body);
- }
- .modal-footer {
- display: flex;
- gap: var(--space-2);
- justify-content: flex-end;
- margin-top: var(--space-4);
- }
- .btn {
- height: 28px;
- padding: 0 var(--space-3);
- border: 0;
- border-radius: var(--radius-sm);
- font: var(--text-headline);
- cursor: pointer;
- color: var(--text-primary);
- display: inline-flex;
- align-items: center;
- justify-content: center;
- }
- .btn:disabled { opacity: 0.45; cursor: not-allowed; }
- .btn-connect {
- background: var(--accent);
- color: var(--text-inverse);
- }
- @media (prefers-reduced-motion: no-preference) {
- .btn, .btn-connect {
- transition: background-color var(--dur-fast) var(--ease-out),
- filter var(--dur-fast) var(--ease-out);
- }
- }
- .btn-connect:hover:not(:disabled) { filter: brightness(1.08); }
- .btn-connect:active:not(:disabled) { filter: brightness(0.94); }
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json
index 8de23e7..b477aeb 100644
--- a/frontend/src/i18n/en.json
+++ b/frontend/src/i18n/en.json
@@ -1,11 +1,9 @@
{
"app": {
- "name": "WireGuide",
"disconnected": "Disconnected",
"connecting": "Connecting...",
"connected": "Connected",
- "no_handshake": "No Handshake",
- "error": "Error"
+ "no_handshake": "No Handshake"
},
"nav": {
"tunnels": "Tunnels",
@@ -63,21 +61,6 @@
"rx": "Download",
"tx": "Upload",
"latency": "Latency",
- "wifi_auto_connect": "Auto-Connect",
- "wifi_auto_connect_hint": "This tunnel activates when you join one of these networks.",
- "wifi_empty": "No networks added yet.",
- "wifi_added": "Auto-connect networks",
- "wifi_known": "Saved networks",
- "wifi_current": "Current",
- "wifi_current_tooltip": "You are connected to this network now",
- "wifi_manual": "Add a network not listed above",
- "wifi_manual_hint": "Type an SSID exactly as it appears on the network you want to match.",
- "wifi_no_known": "No saved Wi-Fi networks were found. Use the manual input below.",
- "wifi_pick_label": "Networks that should auto-connect this tunnel",
- "wifi_combo_placeholder": "Type or pick a network…",
- "kill_switch": "Kill Switch",
- "dns_protection": "DNS Protection",
- "details": "Details",
"rename_hint": "Double-click to rename",
"notes": "Notes",
"notes_placeholder": "Freeform notes — endpoint location, credentials hint, etc.",
@@ -86,21 +69,8 @@
"sort_added_old": "Oldest first"
},
"import": {
- "title": "Import Tunnel",
- "drop_file": "Drop .conf file here",
- "or": "or",
- "select_file": "Select File",
- "from_clipboard": "Paste from Clipboard",
- "preview_title": "Preview",
- "tunnel_name": "Tunnel Name",
- "confirm": "Import",
- "cancel": "Cancel",
- "success": "Tunnel imported successfully",
- "exists": "Tunnel \"{name}\" already exists. Overwrite?",
- "overwrite": "Overwrite",
"zip_result_title": "Import Results",
"zip_ok": "Done",
- "zip_none": "No .conf files found in zip",
"zip_summary": "{ok} imported, {fail} failed",
"zip_summary_ok": "{ok} imported"
},
@@ -108,17 +78,9 @@
"title": "Edit Tunnel: {name}",
"save": "Save",
"cancel": "Cancel",
- "validation_error": "Validation Error",
"name_placeholder": "Tunnel name",
"name_required": "Tunnel name is required"
},
- "scripts": {
- "warning_title": "Script Warning",
- "warning_message": "This configuration contains system commands that will be executed:",
- "allow": "Allow",
- "deny": "Deny",
- "denied_note": "Scripts will not be executed for this tunnel."
- },
"settings": {
"title": "Settings",
"general": "General",
@@ -133,7 +95,6 @@
"lang_auto": "Auto",
"close": "Close",
"advanced": "Advanced",
- "wifi_rules": "Wi-Fi Rules",
"log_level": "Log Level",
"log_level_debug": "Debug",
"log_level_info": "Info",
@@ -150,13 +111,10 @@
"about": "About",
"about_tagline": "Friendly WireGuard for every desktop.",
"about_desc": "An open-source VPN client built around WireGuard — drop a config and you're online. A kill switch that doesn't leak, DNS protection that doesn't dodge, and Wi-Fi rules that pick the right tunnel on networks you trust. No command line. No telemetry. No upsell.",
- "about_source": "Source on GitHub",
"about_releases": "Release notes",
"about_issues": "Report an issue",
"about_license": "License (MIT)",
"about_credits": "Built with WireGuard®, Wails, and Svelte.",
- "about_copyright": "© 2026 WireGuide. Released under the MIT License.",
- "about_check_update": "Check for updates",
"about_made_by": "Made by",
"about_footer_copyright": "© 2026 WireGuide",
"up_to_date": "You're on the latest version.",
@@ -174,41 +132,15 @@
"delete_message": "Are you sure you want to delete \"{name}\"?",
"yes": "Delete",
"no": "Cancel",
- "close": "Close",
"disconnect_first": "Disconnect the tunnel first"
},
- "error": {
- "connection_failed": "Connection failed",
- "network_error": "Network error — check your internet connection",
- "key_mismatch": "Key mismatch — verify your configuration",
- "permission_denied": "Administrator privileges required",
- "invalid_config": "Invalid configuration",
- "file_read_error": "Cannot read file — check file permissions",
- "empty_file": "Empty file"
- },
- "time": {
- "seconds_ago": "{n}s ago",
- "just_now": "just now"
- },
"log": {
- "title": "Logs",
"clear": "Clear",
"copy": "Copy",
"auto_scroll": "Auto-scroll",
"no_entries": "No log entries",
"filter_all": "All"
},
- "notify": {
- "disconnected": "VPN connection lost",
- "reconnecting": "Reconnecting to {name}...",
- "reconnected": "Reconnected to {name}",
- "kill_switch_active": "Kill switch active — internet blocked",
- "error": "VPN error: {message}"
- },
- "daemon": {
- "not_running": "WireGuide daemon is not running",
- "install_hint": "Start the daemon with: sudo wireguided"
- },
"tools": {
"tab_dns_leak": "DNS Leak Test",
"tab_routes": "Routes",
@@ -229,44 +161,9 @@
"route_legend_vpn": "VPN route",
"route_legend_direct": "Direct route"
},
- "new_tunnel": {
- "title": "New Tunnel",
- "mode_form": "Form",
- "mode_text": "Text",
- "section_interface": "Interface",
- "name_label": "Name",
- "name_placeholder": "my-tunnel",
- "private_key_label": "Private Key",
- "generate": "Generate",
- "address_label": "Address",
- "dns_label": "DNS",
- "mtu_label": "MTU",
- "section_peer": "Peer",
- "peer_public_key_label": "Public Key",
- "peer_psk_label": "Preshared Key (optional)",
- "peer_endpoint_label": "Endpoint",
- "peer_allowed_ips_label": "Allowed IPs",
- "peer_keepalive_label": "Persistent Keepalive",
- "create": "Create",
- "cancel": "Cancel"
- },
- "keygen": {
- "private_key": "Private Key",
- "public_key": "Public Key",
- "generate": "Generate New Keypair",
- "copy": "Copy"
- },
- "split_tunnel": {
- "title": "Split Tunnel",
- "no_subnets": "No subnets added",
- "add": "Add",
- "subnet_placeholder": "10.0.0.0/24"
- },
"stats": {
"speed_graph": "Speed Graph",
- "waiting": "Waiting for data...",
- "rx": "Download",
- "tx": "Upload"
+ "waiting": "Waiting for data..."
},
"conflict": {
"title": "Routing Conflict Detected",
@@ -287,11 +184,8 @@
"vpn_warning": "The VPN connection will be briefly interrupted during the update. The app will restart automatically.",
"proceed": "Update Now",
"cancel": "Later",
- "copy_command": "Copy",
- "copied": "Copied",
"check_now": "Check now",
"checking": "Checking…",
- "up_to_date": "You're on the latest version ({version})",
"last_checked": "Last checked {time}",
"never_checked": "Never checked",
"first_check_scheduled": "First check scheduled…",
@@ -308,13 +202,6 @@
"desc": "Wi-Fi auto-connect rules need access to your current network name. Grant permission in System Settings → Privacy & Security → Location Services.",
"open_settings": "Open Settings"
},
- "wifi_rules": {
- "trusted_ssids": "Trusted Networks",
- "trusted_hint": "On these networks, all auto-managed tunnels disconnect automatically.",
- "no_trusted": "No trusted networks added.",
- "add": "Add",
- "ssid_placeholder": "Network name (SSID)"
- },
"automation": {
"title": "Automation",
"hint": "Rules run top to bottom; the first matching condition wins. Pick a condition and type its value, or use the suggestion for the network you're on. \"This network\" matches a specific router by its gateway MAC (precise; tells apart networks that share a subnet); \"subnet\" matches an IP range. Add separate rules to connect and to disconnect.",
@@ -330,15 +217,10 @@
"cond_none": "otherwise",
"cond_none_desc": "no other rule matched",
"ssid_placeholder": "Wi-Fi name",
- "subnet_current": "current network",
"cond_network": "on this network",
- "use_current": "Use current",
- "net_captured": "this network",
- "net_not_set": "click \"Use current\" while on the network",
- "net_placeholder": "pick a network or click \"Use current\"",
"mac_placeholder": "gateway MAC (e.g. b0:38:6c:54:8b:ab)",
"drag_hint": "drag to reorder — the topmost matching rule wins",
"mac_invalid": "not a valid MAC address (e.g. b0:38:6c:54:8b:ab)",
"subnet_invalid": "not a valid CIDR (e.g. 192.168.0.0/24)"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js
index b886e96..d27ecb9 100644
--- a/frontend/src/i18n/index.js
+++ b/frontend/src/i18n/index.js
@@ -48,13 +48,5 @@ function translate(lang, key, params = {}) {
// `svelte-i18n`), conceptually equivalent to Swift's `L.tr("key")`.
export const t = derived(locale, ($locale) => (key, params) => translate($locale, key, params));
-// Non-reactive translator for plain-JS call sites (notifications, log
-// formatting, etc.) that aren't tied to the Svelte render tree. Reads
-// the current locale once per call. Do NOT use this inside `.svelte`
-// templates — use `$t(...)` there so re-renders happen on language change.
-export function tPlain(key, params = {}) {
- return translate(get(locale), key, params);
-}
-
// Initialize with detected language
locale.set(detectLanguage());
diff --git a/frontend/src/i18n/ja.json b/frontend/src/i18n/ja.json
index e232cc4..a3d5ca0 100644
--- a/frontend/src/i18n/ja.json
+++ b/frontend/src/i18n/ja.json
@@ -1,11 +1,9 @@
{
"app": {
- "name": "WireGuide",
"disconnected": "未接続",
"connecting": "接続中…",
"connected": "接続済み",
- "no_handshake": "ハンドシェイクなし",
- "error": "エラー"
+ "no_handshake": "ハンドシェイクなし"
},
"nav": {
"tunnels": "トンネル",
@@ -63,21 +61,6 @@
"rx": "ダウンロード",
"tx": "アップロード",
"latency": "遅延",
- "wifi_auto_connect": "自動接続",
- "wifi_auto_connect_hint": "これらのネットワークに接続するとこのトンネルが自動で起動します。",
- "wifi_empty": "登録されたネットワークがありません。",
- "wifi_added": "自動接続ネットワーク",
- "wifi_known": "保存済みネットワーク",
- "wifi_current": "現在",
- "wifi_current_tooltip": "現在このネットワークに接続中です",
- "wifi_manual": "リストにないネットワークを追加",
- "wifi_manual_hint": "対象ネットワークの SSID を正確に入力してください。",
- "wifi_no_known": "保存済み Wi-Fi ネットワークが見つかりませんでした。下から手動で入力してください。",
- "wifi_pick_label": "このトンネルを自動で起動するネットワーク",
- "wifi_combo_placeholder": "ネットワークを入力または選択…",
- "kill_switch": "キルスイッチ",
- "dns_protection": "DNS 保護",
- "details": "詳細",
"rename_hint": "ダブルクリックで名前変更",
"notes": "メモ",
"notes_placeholder": "エンドポイントの場所、認証情報のヒントなど自由メモ",
@@ -86,21 +69,8 @@
"sort_added_old": "古い順"
},
"import": {
- "title": "トンネルのインポート",
- "drop_file": ".conf ファイルをここにドロップ",
- "or": "または",
- "select_file": "ファイルを選択",
- "from_clipboard": "クリップボードから貼り付け",
- "preview_title": "プレビュー",
- "tunnel_name": "トンネル名",
- "confirm": "インポート",
- "cancel": "キャンセル",
- "success": "トンネルをインポートしました",
- "exists": "トンネル \"{name}\" はすでに存在します。上書きしますか?",
- "overwrite": "上書き",
"zip_result_title": "インポート結果",
"zip_ok": "完了",
- "zip_none": "zip 内に .conf ファイルが見つかりませんでした",
"zip_summary": "{ok}件インポート、{fail}件失敗",
"zip_summary_ok": "{ok}件インポート"
},
@@ -108,17 +78,9 @@
"title": "トンネル編集: {name}",
"save": "保存",
"cancel": "キャンセル",
- "validation_error": "検証エラー",
"name_placeholder": "トンネル名",
"name_required": "トンネル名を入力してください"
},
- "scripts": {
- "warning_title": "スクリプト警告",
- "warning_message": "この設定には実行されるシステムコマンドが含まれています:",
- "allow": "許可",
- "deny": "拒否",
- "denied_note": "このトンネルではスクリプトは実行されません。"
- },
"settings": {
"title": "設定",
"general": "一般",
@@ -133,7 +95,6 @@
"lang_auto": "自動",
"close": "閉じる",
"advanced": "詳細",
- "wifi_rules": "Wi-Fi ルール",
"log_level": "ログレベル",
"log_level_debug": "デバッグ",
"log_level_info": "情報",
@@ -150,13 +111,10 @@
"about": "情報",
"about_tagline": "デスクトップのための、親しみやすい WireGuard。",
"about_desc": "WireGuard を中心に作られたオープンソースの VPN クライアント。設定ファイルをドロップすればすぐにつながります。漏えいしないキルスイッチ、抜け道のない DNS 保護、信頼するネットワークで自動的にトンネルを開く Wi-Fi ルール。コマンドラインも、テレメトリも、勧誘もありません。",
- "about_source": "GitHub ソースコード",
"about_releases": "リリースノート",
"about_issues": "問題を報告",
"about_license": "ライセンス (MIT)",
"about_credits": "WireGuide®、Wails、Svelte で構築。",
- "about_copyright": "© 2026 WireGuide. MIT ライセンスで公開。",
- "about_check_update": "アップデートを確認",
"about_made_by": "作者",
"about_footer_copyright": "© 2026 WireGuide",
"section_appearance": "外観",
@@ -174,41 +132,15 @@
"delete_message": "\"{name}\" を削除しますか?",
"yes": "削除",
"no": "キャンセル",
- "close": "閉じる",
"disconnect_first": "先にトンネルを切断してください"
},
- "error": {
- "connection_failed": "接続に失敗しました",
- "network_error": "ネットワークエラー — インターネット接続を確認してください",
- "key_mismatch": "鍵の不一致 — 設定を確認してください",
- "permission_denied": "管理者権限が必要です",
- "invalid_config": "無効な設定",
- "file_read_error": "ファイルを読み込めません — ファイル権限を確認してください",
- "empty_file": "空のファイルです"
- },
- "time": {
- "seconds_ago": "{n}秒前",
- "just_now": "今"
- },
"log": {
- "title": "ログ",
"clear": "クリア",
"auto_scroll": "自動スクロール",
"no_entries": "ログがありません",
"filter_all": "すべて",
"copy": "コピー"
},
- "notify": {
- "disconnected": "VPN 接続が切断されました",
- "reconnecting": "{name} に再接続中…",
- "reconnected": "{name} に再接続しました",
- "kill_switch_active": "キルスイッチ有効 — インターネットをブロック中",
- "error": "VPN エラー: {message}"
- },
- "daemon": {
- "not_running": "WireGuide デーモンが実行されていません",
- "install_hint": "デーモン起動: sudo wireguided"
- },
"tools": {
"tab_dns_leak": "DNS 漏洩テスト",
"tab_routes": "ルート",
@@ -229,44 +161,9 @@
"route_legend_vpn": "VPN ルート",
"route_legend_direct": "直接ルート"
},
- "new_tunnel": {
- "title": "新しいトンネル",
- "mode_form": "フォーム",
- "mode_text": "テキスト",
- "section_interface": "インターフェース",
- "name_label": "名前",
- "name_placeholder": "my-tunnel",
- "private_key_label": "秘密鍵",
- "generate": "生成",
- "address_label": "アドレス",
- "dns_label": "DNS",
- "mtu_label": "MTU",
- "section_peer": "ピア",
- "peer_public_key_label": "公開鍵",
- "peer_psk_label": "事前共有鍵 (任意)",
- "peer_endpoint_label": "エンドポイント",
- "peer_allowed_ips_label": "許可 IP",
- "peer_keepalive_label": "Persistent Keepalive",
- "create": "作成",
- "cancel": "キャンセル"
- },
- "keygen": {
- "private_key": "秘密鍵",
- "public_key": "公開鍵",
- "generate": "新しい鍵ペアを生成",
- "copy": "コピー"
- },
- "split_tunnel": {
- "title": "スプリットトンネル",
- "no_subnets": "サブネットが追加されていません",
- "add": "追加",
- "subnet_placeholder": "10.0.0.0/24"
- },
"stats": {
"speed_graph": "速度グラフ",
- "waiting": "データ待機中…",
- "rx": "ダウンロード",
- "tx": "アップロード"
+ "waiting": "データ待機中…"
},
"conflict": {
"title": "ルーティング競合を検出",
@@ -287,11 +184,8 @@
"vpn_warning": "更新中に VPN 接続が一時的に切断される場合があります。アプリは自動的に再起動します。",
"proceed": "更新する",
"cancel": "あとで",
- "copy_command": "コピー",
- "copied": "コピーしました",
"check_now": "今すぐ確認",
"checking": "確認中…",
- "up_to_date": "最新バージョンです ({version})",
"last_checked": "前回 {time}",
"never_checked": "未確認",
"first_check_scheduled": "まもなく初回確認…",
@@ -308,13 +202,6 @@
"desc": "Wi-Fi 自動接続ルールが動作するには、現在のネットワーク名へのアクセスが必要です。System Settings → プライバシーとセキュリティ → 位置情報サービスで許可してください。",
"open_settings": "設定を開く"
},
- "wifi_rules": {
- "trusted_ssids": "信頼するネットワーク",
- "trusted_hint": "これらのネットワークに接続すると、自動接続中のトンネルがすべて自動的に切断されます。",
- "no_trusted": "信頼するネットワークが追加されていません。",
- "add": "追加",
- "ssid_placeholder": "ネットワーク名 (SSID)"
- },
"automation": {
"title": "オートメーション",
"hint": "ルールは上から順に評価され、最初に一致した条件が適用されます。条件を選んで値を入力するか、現在のネットワークの候補を使用してください。「このネットワーク」はゲートウェイMACで特定のルーターを正確に識別し(サブネットが重複しても区別)、「サブネット」はIP範囲で一致します。接続/切断のルールを個別に追加できます。",
@@ -330,15 +217,10 @@
"cond_none": "それ以外",
"cond_none_desc": "他のルールに一致しないとき",
"ssid_placeholder": "Wi-Fi名",
- "subnet_current": "現在のネットワーク",
"cond_network": "このネットワークで",
- "use_current": "現在を使用",
- "net_captured": "このネットワーク",
- "net_not_set": "対象ネットワークで「現在を使用」をクリック",
- "net_placeholder": "ネットワークを選択、または「現在を使用」",
"mac_placeholder": "ゲートウェイMAC (例: b0:38:6c:54:8b:ab)",
"drag_hint": "ドラッグで並べ替え — 最上位の一致ルールが優先",
"mac_invalid": "有効なMACアドレスではありません (例: b0:38:6c:54:8b:ab)",
"subnet_invalid": "有効なCIDRではありません (例: 192.168.0.0/24)"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/i18n/ko.json b/frontend/src/i18n/ko.json
index 33156a1..5bd1cbc 100644
--- a/frontend/src/i18n/ko.json
+++ b/frontend/src/i18n/ko.json
@@ -1,11 +1,9 @@
{
"app": {
- "name": "WireGuide",
"disconnected": "연결 안 됨",
"connecting": "연결 중…",
"connected": "연결됨",
- "no_handshake": "핸드셰이크 없음",
- "error": "오류"
+ "no_handshake": "핸드셰이크 없음"
},
"nav": {
"tunnels": "터널",
@@ -63,21 +61,6 @@
"rx": "다운로드",
"tx": "업로드",
"latency": "지연 시간",
- "wifi_auto_connect": "자동 연결",
- "wifi_auto_connect_hint": "이 네트워크에 접속하면 이 터널이 자동으로 켜집니다.",
- "wifi_empty": "추가된 네트워크가 없습니다.",
- "wifi_added": "자동 연결 네트워크",
- "wifi_known": "저장된 네트워크",
- "wifi_current": "현재",
- "wifi_current_tooltip": "지금 이 네트워크에 연결되어 있습니다",
- "wifi_manual": "목록에 없는 네트워크 추가",
- "wifi_manual_hint": "원하는 네트워크의 SSID를 정확히 입력하세요.",
- "wifi_no_known": "저장된 Wi-Fi 네트워크를 찾지 못했습니다. 아래에서 직접 입력하세요.",
- "wifi_pick_label": "이 터널을 자동으로 켤 네트워크",
- "wifi_combo_placeholder": "네트워크 입력 또는 선택…",
- "kill_switch": "킬 스위치",
- "dns_protection": "DNS 보호",
- "details": "상세 정보",
"rename_hint": "두 번 클릭해서 이름 변경",
"notes": "메모",
"notes_placeholder": "엔드포인트 위치, 자격증명 힌트 등 자유 메모",
@@ -86,21 +69,8 @@
"sort_added_old": "오래된 추가순"
},
"import": {
- "title": "터널 가져오기",
- "drop_file": ".conf 파일을 여기에 놓으세요",
- "or": "또는",
- "select_file": "파일 선택",
- "from_clipboard": "클립보드에서 붙여넣기",
- "preview_title": "미리보기",
- "tunnel_name": "터널 이름",
- "confirm": "가져오기",
- "cancel": "취소",
- "success": "터널을 가져왔습니다",
- "exists": "터널 \"{name}\"이(가) 이미 있습니다. 덮어쓰시겠습니까?",
- "overwrite": "덮어쓰기",
"zip_result_title": "가져오기 결과",
"zip_ok": "완료",
- "zip_none": "zip에서 .conf 파일을 찾을 수 없습니다",
"zip_summary": "{ok}개 가져옴, {fail}개 실패",
"zip_summary_ok": "{ok}개 가져옴"
},
@@ -108,17 +78,9 @@
"title": "터널 편집: {name}",
"save": "저장",
"cancel": "취소",
- "validation_error": "유효성 검사 오류",
"name_placeholder": "터널 이름",
"name_required": "터널 이름을 입력하세요"
},
- "scripts": {
- "warning_title": "스크립트 경고",
- "warning_message": "이 설정에는 실행될 시스템 명령이 포함되어 있습니다:",
- "allow": "허용",
- "deny": "거부",
- "denied_note": "이 터널의 스크립트는 실행되지 않습니다."
- },
"settings": {
"title": "설정",
"general": "일반",
@@ -133,7 +95,6 @@
"lang_auto": "자동",
"close": "닫기",
"advanced": "고급",
- "wifi_rules": "Wi-Fi 규칙",
"log_level": "로그 수준",
"log_level_debug": "디버그",
"log_level_info": "정보",
@@ -150,13 +111,10 @@
"about": "정보",
"about_tagline": "데스크톱을 위한 친근한 WireGuard.",
"about_desc": "WireGuard를 중심으로 만든 오픈소스 VPN 클라이언트. 설정 파일을 끌어다 놓으면 바로 연결됩니다. 누수 없는 킬 스위치, 빈틈 없는 DNS 보호, 신뢰하는 네트워크에서 자동으로 터널을 켜는 Wi-Fi 규칙까지. 명령줄도, 텔레메트리도, 영업도 없습니다.",
- "about_source": "GitHub 소스 코드",
"about_releases": "릴리스 노트",
"about_issues": "문제 신고",
"about_license": "라이선스 (MIT)",
"about_credits": "WireGuide®, Wails, Svelte 기반.",
- "about_copyright": "© 2026 WireGuide. MIT 라이선스로 공개.",
- "about_check_update": "업데이트 확인",
"about_made_by": "만든 사람",
"about_footer_copyright": "© 2026 WireGuide",
"section_appearance": "외관",
@@ -174,41 +132,15 @@
"delete_message": "\"{name}\"을(를) 삭제하시겠습니까?",
"yes": "삭제",
"no": "취소",
- "close": "닫기",
"disconnect_first": "먼저 연결을 해제하세요"
},
- "error": {
- "connection_failed": "연결 실패",
- "network_error": "네트워크 오류 — 인터넷 연결을 확인하세요",
- "key_mismatch": "키 불일치 — 설정을 확인하세요",
- "permission_denied": "관리자 권한이 필요합니다",
- "invalid_config": "유효하지 않은 설정",
- "file_read_error": "파일을 읽을 수 없습니다 — 파일 권한을 확인하세요",
- "empty_file": "빈 파일입니다"
- },
- "time": {
- "seconds_ago": "{n}초 전",
- "just_now": "방금"
- },
"log": {
- "title": "로그",
"clear": "지우기",
"auto_scroll": "자동 스크롤",
"no_entries": "로그 항목 없음",
"filter_all": "전체",
"copy": "복사"
},
- "notify": {
- "disconnected": "VPN 연결이 끊겼습니다",
- "reconnecting": "{name}에 재연결 중…",
- "reconnected": "{name}에 재연결되었습니다",
- "kill_switch_active": "킬 스위치 활성화 — 인터넷 차단됨",
- "error": "VPN 오류: {message}"
- },
- "daemon": {
- "not_running": "WireGuide 데몬이 실행되지 않고 있습니다",
- "install_hint": "데몬 시작: sudo wireguided"
- },
"tools": {
"tab_dns_leak": "DNS 유출 검사",
"tab_routes": "라우트",
@@ -229,44 +161,9 @@
"route_legend_vpn": "VPN 경로",
"route_legend_direct": "직접 경로"
},
- "new_tunnel": {
- "title": "새 터널",
- "mode_form": "폼",
- "mode_text": "텍스트",
- "section_interface": "인터페이스",
- "name_label": "이름",
- "name_placeholder": "my-tunnel",
- "private_key_label": "개인 키",
- "generate": "생성",
- "address_label": "주소",
- "dns_label": "DNS",
- "mtu_label": "MTU",
- "section_peer": "피어",
- "peer_public_key_label": "공개 키",
- "peer_psk_label": "사전 공유 키 (선택)",
- "peer_endpoint_label": "엔드포인트",
- "peer_allowed_ips_label": "허용 IP",
- "peer_keepalive_label": "Persistent Keepalive",
- "create": "만들기",
- "cancel": "취소"
- },
- "keygen": {
- "private_key": "개인 키",
- "public_key": "공개 키",
- "generate": "키 쌍 새로 생성",
- "copy": "복사"
- },
- "split_tunnel": {
- "title": "스플릿 터널",
- "no_subnets": "추가된 서브넷이 없습니다",
- "add": "추가",
- "subnet_placeholder": "10.0.0.0/24"
- },
"stats": {
"speed_graph": "속도 그래프",
- "waiting": "데이터 대기 중…",
- "rx": "다운로드",
- "tx": "업로드"
+ "waiting": "데이터 대기 중…"
},
"conflict": {
"title": "라우팅 충돌 감지",
@@ -287,11 +184,8 @@
"vpn_warning": "업데이트 중 VPN 연결이 잠시 끊길 수 있습니다. 앱이 자동으로 재시작됩니다.",
"proceed": "업데이트",
"cancel": "나중에",
- "copy_command": "복사",
- "copied": "복사됨",
"check_now": "지금 확인",
"checking": "확인 중…",
- "up_to_date": "최신 버전입니다 ({version})",
"last_checked": "마지막 확인 {time}",
"never_checked": "확인 기록 없음",
"first_check_scheduled": "곧 첫 확인 예정…",
@@ -308,13 +202,6 @@
"desc": "Wi-Fi 자동 연결 규칙이 동작하려면 현재 Wi-Fi 이름에 접근할 수 있어야 합니다. System Settings → 개인 정보 보호 및 보안 → 위치 서비스에서 허용해주세요.",
"open_settings": "설정 열기"
},
- "wifi_rules": {
- "trusted_ssids": "신뢰하는 네트워크",
- "trusted_hint": "이 네트워크에 접속하면 자동 연결된 모든 터널이 자동으로 해제됩니다.",
- "no_trusted": "추가된 신뢰 네트워크가 없습니다.",
- "add": "추가",
- "ssid_placeholder": "네트워크 이름 (SSID)"
- },
"automation": {
"title": "자동화",
"hint": "규칙은 위에서 아래로 평가되며 먼저 맞는 조건이 적용됩니다. 조건을 고르고 값을 직접 입력하거나, 현재 네트워크의 자동완성을 쓰세요. \"이 네트워크\"는 게이트웨이 MAC으로 특정 공유기를 정확히 식별하고(서브넷이 겹쳐도 구분), \"서브넷\"은 IP 대역으로 매칭합니다. 연결/해제 규칙을 각각 추가하세요.",
@@ -330,15 +217,10 @@
"cond_none": "그 외",
"cond_none_desc": "다른 규칙이 안 맞을 때",
"ssid_placeholder": "Wi-Fi 이름",
- "subnet_current": "현재 네트워크",
"cond_network": "이 네트워크에서",
- "use_current": "현재 사용",
- "net_captured": "이 네트워크",
- "net_not_set": "해당 네트워크에서 \"현재 사용\" 클릭",
- "net_placeholder": "네트워크 선택 또는 \"현재 사용\" 클릭",
"mac_placeholder": "게이트웨이 MAC (예: b0:38:6c:54:8b:ab)",
"drag_hint": "드래그해서 순서 변경 — 맨 위의 일치 규칙이 우선",
"mac_invalid": "올바른 MAC 주소가 아닙니다 (예: b0:38:6c:54:8b:ab)",
"subnet_invalid": "올바른 CIDR이 아닙니다 (예: 192.168.0.0/24)"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/lib/AutomationEditor.svelte b/frontend/src/lib/AutomationEditor.svelte
index d6680f4..18a2d9b 100644
--- a/frontend/src/lib/AutomationEditor.svelte
+++ b/frontend/src/lib/AutomationEditor.svelte
@@ -537,15 +537,6 @@
border-color: var(--error-text, #ff453a);
background: color-mix(in srgb, var(--error-text, #ff453a) 8%, var(--bg-primary));
}
- .am-val-network { flex: 1; min-width: 120px; display: inline-flex; align-items: baseline; gap: 6px; font: 400 12px var(--font-sans); color: var(--text-primary); }
- .am-mac { font: 400 10px var(--font-mono); color: var(--text-muted); }
- .am-usecurrent {
- font: 500 11px var(--font-sans); color: var(--accent);
- background: color-mix(in srgb, var(--accent) 10%, transparent);
- border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
- border-radius: 7px; padding: 4px 9px; cursor: pointer; flex-shrink: 0; white-space: nowrap;
- }
- .am-usecurrent:hover { background: color-mix(in srgb, var(--accent) 18%, transparent); }
.am-remove { background: transparent; border: 0; color: var(--text-muted); cursor: pointer; padding: 4px; border-radius: 6px; flex-shrink: 0; }
.am-remove:hover { background: color-mix(in srgb, var(--red, #ff3b30) 18%, transparent); color: var(--red, #ff3b30); }
.am-add {
diff --git a/frontend/src/lib/KeyGenerator.svelte b/frontend/src/lib/KeyGenerator.svelte
deleted file mode 100644
index 1c61dc8..0000000
--- a/frontend/src/lib/KeyGenerator.svelte
+++ /dev/null
@@ -1,102 +0,0 @@
-
-
-
-
- {$t('keygen.generate')}
-
-
- {#if generated}
-
-
- {$t('keygen.private_key')}
- {privateKey.substring(0, 20)}…
-
-
- {$t('keygen.public_key')}
-
- {publicKey}
-
- {copied ? '✓' : $t('keygen.copy')}
-
-
- {/if}
-
-
-
diff --git a/frontend/src/lib/LogViewer.svelte b/frontend/src/lib/LogViewer.svelte
index 4d6ac14..b932d88 100644
--- a/frontend/src/lib/LogViewer.svelte
+++ b/frontend/src/lib/LogViewer.svelte
@@ -120,7 +120,7 @@
- {#each filtered as entry, i (i)}
+ {#each filtered as entry (entry.id)}
{formatTime(entry.time)}
{entry.source}
diff --git a/frontend/src/lib/MiniMode.svelte b/frontend/src/lib/MiniMode.svelte
deleted file mode 100644
index 2165f21..0000000
--- a/frontend/src/lib/MiniMode.svelte
+++ /dev/null
@@ -1,115 +0,0 @@
-
-
-
-
-
- {#if isConnected}
-
- ↓ {formatBytes(status.rx_bytes)}
- ↑ {formatBytes(status.tx_bytes)}
-
- {/if}
-
-
- {isConnected ? $t('tunnel.disconnect') : $t('tunnel.connect')}
-
-
-
-
diff --git a/frontend/src/lib/ScriptWarning.svelte b/frontend/src/lib/ScriptWarning.svelte
deleted file mode 100644
index c69d3e2..0000000
--- a/frontend/src/lib/ScriptWarning.svelte
+++ /dev/null
@@ -1,123 +0,0 @@
-
-
-
dispatch('deny')}>
-
-
{$t('scripts.warning_title')}
-
{$t('scripts.warning_message')}
-
-
- {#each scripts as script}
-
- {script.Hook}
- {script.Command}
-
- {/each}
-
-
-
-
{$t('scripts.denied_note')}
-
-
-
-
diff --git a/frontend/src/lib/Settings.svelte b/frontend/src/lib/Settings.svelte
index a948e35..de6fa7c 100644
--- a/frontend/src/lib/Settings.svelte
+++ b/frontend/src/lib/Settings.svelte
@@ -136,10 +136,6 @@
tray_icon_style: 'color',
auto_update_check: true,
compact_list: false,
- wifi_rules: {
- trusted_ssids: [],
- per_tunnel: {},
- },
};
let loaded = false;
let appVersion = '';
@@ -162,12 +158,6 @@
// the Go side becomes undefined here; default to true to match
// Settings.AutoUpdateCheckEnabled() semantics.
settings.auto_update_check = (s.auto_update_check === false) ? false : true;
- if (s.wifi_rules) {
- settings.wifi_rules = {
- trusted_ssids: s.wifi_rules.trusted_ssids || [],
- per_tunnel: s.wifi_rules.per_tunnel || {},
- };
- }
}
} catch (e) {
console.error('load settings:', e);
@@ -180,8 +170,8 @@
// Re-fetch the freshest settings.json before writing so per-tunnel
// wifi rule edits made in TunnelDetail (which calls SaveSettings
// independently with its own modified per_tunnel map) aren't
- // silently overwritten. We own only `trusted_ssids` in wifi_rules;
- // `per_tunnel` belongs to TunnelDetail. If the fresh fetch fails
+ // silently overwritten. This screen owns NO part of wifi_rules —
+ // both halves are carried from the fresh fetch. If the fresh fetch fails
// (helper restarting, IPC flake) we abort rather than write our
// potentially-stale per_tunnel snapshot — a deferred save is far
// better than clobbering the user's per-tunnel edits.
@@ -216,8 +206,13 @@
// them from the fresh fetch so saving a Settings toggle never
// wipes them.
automation: fresh?.automation,
+ // Legacy Wi-Fi trust rules have no UI on this screen — carry both
+ // halves from the fresh fetch (not a load-time snapshot) so a
+ // Settings toggle can't clobber CLI edits made while it is open.
+ // The Go side still migrates trusted_ssids/per_tunnel into the
+ // Automation model, so the round-trip itself must stay.
wifi_rules: {
- trusted_ssids: settings.wifi_rules?.trusted_ssids || [],
+ trusted_ssids: fresh?.wifi_rules?.trusted_ssids || [],
per_tunnel: perTunnel,
},
});
diff --git a/frontend/src/lib/SplitTunnelUI.svelte b/frontend/src/lib/SplitTunnelUI.svelte
deleted file mode 100644
index 7177952..0000000
--- a/frontend/src/lib/SplitTunnelUI.svelte
+++ /dev/null
@@ -1,149 +0,0 @@
-
-
-
-
- setMode('all')}>
- 0.0.0.0/0, ::/0
-
- setMode('custom')}>
- {$t('split_tunnel.title')}
-
-
-
- {#if mode === 'custom'}
-
- {#each customIPs as ip}
-
- {ip}
- removeSubnet(ip)}>✕
-
- {/each}
- {#if customIPs.length === 0}
-
{$t('split_tunnel.no_subnets')}
- {/if}
-
-
-
- {$t('split_tunnel.add')}
-
- {/if}
-
-
-
diff --git a/frontend/src/lib/TunnelDetail.svelte b/frontend/src/lib/TunnelDetail.svelte
index 8d0d2d4..90f8b89 100644
--- a/frontend/src/lib/TunnelDetail.svelte
+++ b/frontend/src/lib/TunnelDetail.svelte
@@ -145,22 +145,26 @@
latencyTargetSaveTimer = setTimeout(saveLatencyTarget, 600);
}
- function autoLatencyTarget() {
- if (!detail) return { label: $selectedTunnel?.endpoint || '—', fallback: true };
- for (const peer of detail.Peers || []) {
+ // Takes its inputs as parameters (not via closure) so the `$:` below
+ // actually re-runs: the compiler only tracks dependencies referenced in
+ // the reactive statement itself, and a dep-less statement runs exactly
+ // once — which froze this on the first tunnel's endpoint forever.
+ function autoLatencyTarget(det, sel) {
+ if (!det) return { label: sel?.endpoint || '—', fallback: true };
+ for (const peer of det.Peers || []) {
for (const allowed of peer.AllowedIPs || []) {
if (allowed.endsWith('/32')) return { label: allowed.slice(0, -3), fallback: false };
if (allowed.endsWith('/128')) return { label: allowed.slice(0, -4), fallback: false };
}
}
- const fullTunnel = (detail.Peers || []).some(peer =>
+ const fullTunnel = (det.Peers || []).some(peer =>
(peer.AllowedIPs || []).some(ip => ip === '0.0.0.0/0' || ip === '::/0')
);
if (fullTunnel) return { label: '8.8.8.8', fallback: false };
- return { label: $selectedTunnel?.endpoint || '—', fallback: true };
+ return { label: sel?.endpoint || '—', fallback: true };
}
- $: autoLatency = autoLatencyTarget();
+ $: autoLatency = autoLatencyTarget(detail, $selectedTunnel);
$: latencyTargetDisplay = latencyTargetSaved
? latencyTargetSaved
: `${$t('tunnel.latency_target_placeholder')}: ${autoLatency.fallback ? $t('tunnel.endpoint') : autoLatency.label}`;
diff --git a/frontend/src/lib/TunnelList.svelte b/frontend/src/lib/TunnelList.svelte
index 251196a..1c8a651 100644
--- a/frontend/src/lib/TunnelList.svelte
+++ b/frontend/src/lib/TunnelList.svelte
@@ -115,7 +115,7 @@
{$t('tunnel.drop_hint')}
{:else}
- {#each sorted as tun}
+ {#each sorted as tun (tun.name)}
{
s.entries[s.head] = {
+ // Monotonic id — LogViewer keys its {#each} on this. Index keys
+ // break down once the ring wraps: every push shifts all rows by
+ // one, forcing Svelte to rewrite the whole list per log record.
+ id: s.version,
time: e.time,
level: (e.level || 'info').toLowerCase(),
source: e.source || 'gui',
diff --git a/frontend/src/stores/tunnels.js b/frontend/src/stores/tunnels.js
index ab07e8d..a83f6ae 100644
--- a/frontend/src/stores/tunnels.js
+++ b/frontend/src/stores/tunnels.js
@@ -7,38 +7,51 @@ export const connectionStatus = writable({ state: 'disconnected' });
let statusUnsub = null;
+// Last-broadcast fingerprint for the notification gate below.
+let lastStatusJSON = '';
+
// Subscribe to backend status events. The tunnel list is not event-driven
// on the backend side — it's refreshed manually via `refreshTunnels()` after
// each mutating operation (connect/disconnect/create/delete/rename).
+//
+// Every set()/update() here is gated behind a value comparison. Svelte
+// stores treat all objects as unequal (safe_not_equal), so returning the
+// same reference from update() still notifies every subscriber — at the
+// helper's 1 Hz broadcast rate that meant TunnelList re-sorting and every
+// dependent `$:` recomputing each second even while idle.
export function subscribeToEvents() {
unsubscribe();
statusUnsub = Events.On('status', (event) => {
const status = event.data;
- connectionStatus.set(status);
+ const statusJSON = JSON.stringify(status);
+ if (statusJSON !== lastStatusJSON) {
+ lastStatusJSON = statusJSON;
+ connectionStatus.set(status);
+ }
// Sync is_connected flag on tunnel objects. The backend now sends
// active_tunnels (array of connected tunnel names) to support
// multiple simultaneous tunnels.
const activeSet = new Set(status?.active_tunnels || []);
- tunnels.update((list) => {
- let changed = false;
- const next = list.map((t) => {
- const conn = activeSet.has(t.name);
- if (t.is_connected === conn) return t;
- changed = true;
- return { ...t, is_connected: conn };
- });
- return changed ? next : list;
+ const list = get(tunnels);
+ let changed = false;
+ const next = list.map((t) => {
+ const conn = activeSet.has(t.name);
+ if (t.is_connected === conn) return t;
+ changed = true;
+ return { ...t, is_connected: conn };
});
+ if (changed) tunnels.set(next);
- selectedTunnel.update((sel) => {
- if (!sel) return sel;
+ const sel = get(selectedTunnel);
+ if (sel) {
const nowConnected = activeSet.has(sel.name);
- if (sel.is_connected === nowConnected) return sel;
- return { ...sel, is_connected: nowConnected };
- });
+ if (sel.is_connected !== nowConnected) {
+ selectedTunnel.set({ ...sel, is_connected: nowConnected });
+ }
+ }
});
}
@@ -78,7 +91,10 @@ export async function refreshTunnels(TunnelService) {
export async function refreshStatus(TunnelService) {
try {
const status = await TunnelService.GetStatus();
- if (status) connectionStatus.set(status);
+ if (status) {
+ lastStatusJSON = JSON.stringify(status);
+ connectionStatus.set(status);
+ }
} catch (e) {
console.error('status error:', e);
}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index ca0dde4..b13e9ed 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -3,54 +3,16 @@ import { svelte } from "@sveltejs/vite-plugin-svelte";
import wails from "@wailsio/runtime/plugins/vite";
// https://vitejs.dev/config/
-//
-// Bundle split policy: the previous single 531 KB chunk delays cold start
-// because the whole app (Settings, LogViewer, TunnelDetail, KeyGenerator)
-// has to parse before the first paint. We extract each modal/heavy
-// component into its own chunk so the initial render only needs the
-// tunnel-list path; modals load on demand when the user opens them.
export default defineConfig({
plugins: [svelte(), wails("./bindings")],
build: {
- // Bump chunk size warning ceiling because the runtime+Svelte combined
- // chunk is still ~300 KB even after splitting — that's expected for a
- // Svelte+Wails app and not actionable.
+ // WebKitGTK versions shipped by supported Linux distributions lag the
+ // evergreen browsers Vite targets by default. Transpile modern syntax so
+ // the application mounts on those embedded WebKit runtimes as well.
+ target: "safari13",
+ // Let Rolldown derive safe chunk boundaries. The previous forced chunk
+ // graph created circular startup imports under Vite 8 and left WebKitGTK
+ // with a blank window before the Svelte application could mount.
chunkSizeWarningLimit: 600,
- rollupOptions: {
- output: {
- manualChunks: {
- // Wails bindings + runtime — heavy and rarely changes; cache
- // separately so app code updates don't bust this chunk.
- "wails-runtime": ["@wailsio/runtime"],
- // CodeMirror — ~250 KB just for the editor framework + every
- // language pack we import. ConfigEditor is the only consumer
- // today but isolating CodeMirror into its own chunk means the
- // editor view shows the chrome immediately and the heavy
- // syntax-highlight code streams in next, instead of blocking
- // the first paint.
- "codemirror": [
- "@codemirror/autocomplete",
- "@codemirror/commands",
- "@codemirror/language",
- "@codemirror/lint",
- "@codemirror/search",
- "@codemirror/state",
- "@codemirror/view",
- ],
- // Heavy modals — loaded only when the user opens them. Keeping
- // them out of the main chunk shaves ~150 KB off the initial
- // load.
- "modal-logs": ["./src/lib/LogViewer.svelte"],
- "modal-settings": ["./src/lib/Settings.svelte"],
- "modal-tunnel-detail": ["./src/lib/TunnelDetail.svelte"],
- "modal-config-editor": ["./src/lib/ConfigEditor.svelte"],
- "modal-keygen": ["./src/lib/KeyGenerator.svelte"],
- "modal-history": ["./src/lib/History.svelte"],
- "modal-route-viz": ["./src/lib/RouteVisualization.svelte"],
- "modal-stats": ["./src/lib/StatsDashboard.svelte"],
- "modal-dnsleak": ["./src/lib/DNSLeakTest.svelte"],
- },
- },
- },
},
});
diff --git a/go.mod b/go.mod
index 7d4927c..2f787b8 100644
--- a/go.mod
+++ b/go.mod
@@ -1,16 +1,15 @@
module github.com/korjwl1/wireguide
-go 1.25.0
+go 1.25.12
require (
github.com/Microsoft/go-winio v0.6.2
github.com/godbus/dbus/v5 v5.2.2
github.com/makiuchi-d/gozxing v0.1.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
- golang.org/x/crypto v0.49.0
- golang.org/x/image v0.35.0
- golang.org/x/sys v0.42.0
- golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
+ golang.org/x/image v0.44.0
+ golang.org/x/sys v0.47.0
+ golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
)
@@ -25,8 +24,8 @@ require (
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
- github.com/go-git/go-billy/v5 v5.7.0 // indirect
- github.com/go-git/go-git/v5 v5.16.4 // indirect
+ github.com/go-git/go-billy/v5 v5.9.0 // indirect
+ github.com/go-git/go-git/v5 v5.19.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
@@ -44,7 +43,7 @@ require (
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
- github.com/pjbgf/sha1cd v0.5.0 // indirect
+ github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.52.0 // indirect
@@ -52,9 +51,10 @@ require (
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
- golang.org/x/net v0.51.0 // indirect
- golang.org/x/sync v0.20.0 // indirect
- golang.org/x/text v0.35.0 // indirect
+ golang.org/x/crypto v0.54.0 // indirect
+ golang.org/x/net v0.57.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
diff --git a/go.sum b/go.sum
index fe66fb5..8ebfa0a 100644
--- a/go.sum
+++ b/go.sum
@@ -32,12 +32,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
-github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
-github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
+github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
+github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
-github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
-github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
+github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
+github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
@@ -94,8 +94,8 @@ github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE9
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
-github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
-github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
+github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
+github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
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=
@@ -126,17 +126,17 @@ github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyG
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
-golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
-golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
-golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
-golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
-golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
+golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
+golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
-golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
+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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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=
@@ -146,14 +146,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+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.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
-golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
+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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -161,8 +161,8 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1N
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
-golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
-golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
+golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
+golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/app/settings_ops.go b/internal/app/settings_ops.go
index 1c0350a..fea647e 100644
--- a/internal/app/settings_ops.go
+++ b/internal/app/settings_ops.go
@@ -182,6 +182,14 @@ func (s *TunnelService) SaveAutomationRules(tunnel string, rules []wifi.Rule) er
if tunnel == "" {
return fmt.Errorf("automation: empty tunnel name")
}
+ // Reject malformed rules up front — the helper's evaluator silently
+ // no-ops on rules it can't interpret, so a bad save would otherwise
+ // look accepted while doing nothing.
+ for i, r := range rules {
+ if err := wifi.ValidateRule(r); err != nil {
+ return fmt.Errorf("automation: rule %d: %w", i+1, err)
+ }
+ }
return s.settingsStore.Update(func(st *storage.Settings) error {
st.EnsureAutomation()
if len(rules) == 0 {
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index bdabf0f..16ea77a 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -8,12 +8,14 @@ package cli
import (
"context"
+ "encoding/json"
"fmt"
"io"
"log/slog"
"net"
"os"
"path/filepath"
+ "slices"
"strconv"
"strings"
"time"
@@ -78,8 +80,8 @@ func usage(w io.Writer) {
fmt.Fprint(w, `wireguide ctl — control the WireGuide helper from the command line
Tunnels:
- wireguide ctl status show connection status
- wireguide ctl list list tunnels (● = connected)
+ wireguide ctl status [--json] show connection status
+ wireguide ctl list [--json] list tunnels (● = connected)
wireguide ctl connect connect a tunnel
wireguide ctl disconnect [name] disconnect one tunnel (or all)
wireguide ctl import [name] import a .conf (name defaults to filename)
@@ -153,7 +155,9 @@ func tunnelStore() (*storage.TunnelStore, error) {
return storage.NewTunnelStore(paths.TunnelsDir), nil
}
-func cmdStatus(_ []string) int {
+func cmdStatus(args []string) int {
+ jsonOut := hasFlag(args, "--json")
+
c, err := dialHelper()
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -167,6 +171,9 @@ func cmdStatus(_ []string) int {
return 1
}
if len(active.Names) == 0 {
+ if jsonOut {
+ return printJSON([]domain.ConnectionStatus{})
+ }
fmt.Println("disconnected")
return 0
}
@@ -180,6 +187,9 @@ func cmdStatus(_ []string) int {
if len(rows) == 0 {
rows = []domain.ConnectionStatus{st}
}
+ if jsonOut {
+ return printJSON(rows)
+ }
for _, r := range rows {
hs := r.LastHandshake
if hs == "" {
@@ -191,7 +201,16 @@ func cmdStatus(_ []string) int {
return 0
}
-func cmdList(_ []string) int {
+// tunnelListEntry is the --json shape for `ctl list`; domain.ConnectionStatus
+// doesn't apply here since a listed tunnel may never have been connected.
+type tunnelListEntry struct {
+ Name string `json:"name"`
+ Active bool `json:"active"`
+}
+
+func cmdList(args []string) int {
+ jsonOut := hasFlag(args, "--json")
+
store, err := tunnelStore()
if err != nil {
fmt.Fprintln(os.Stderr, "list:", err)
@@ -214,6 +233,13 @@ func cmdList(_ []string) int {
}
c.Close()
}
+ if jsonOut {
+ entries := make([]tunnelListEntry, len(names))
+ for i, n := range names {
+ entries[i] = tunnelListEntry{Name: n, Active: activeSet[n]}
+ }
+ return printJSON(entries)
+ }
if len(names) == 0 {
fmt.Println("(no tunnels)")
return 0
@@ -228,6 +254,23 @@ func cmdList(_ []string) int {
return 0
}
+// hasFlag reports whether flag is present anywhere in args.
+func hasFlag(args []string, flag string) bool {
+ return slices.Contains(args, flag)
+}
+
+// printJSON marshals v as indented JSON to stdout. Always returns 0 unless
+// marshalling itself fails.
+func printJSON(v any) int {
+ data, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "json:", err)
+ return 1
+ }
+ fmt.Println(string(data))
+ return 0
+}
+
func cmdConnect(args []string) int {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: wireguide ctl connect ")
diff --git a/internal/config/keygen.go b/internal/config/keygen.go
deleted file mode 100644
index f128dda..0000000
--- a/internal/config/keygen.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package config
-
-import (
- "crypto/rand"
- "encoding/base64"
-
- "golang.org/x/crypto/curve25519"
-)
-
-// KeyPair holds a WireGuard private/public key pair.
-type KeyPair struct {
- PrivateKey string `json:"private_key"`
- PublicKey string `json:"public_key"`
-}
-
-// GenerateKeyPair creates a new WireGuard key pair (Curve25519).
-func GenerateKeyPair() (*KeyPair, error) {
- // Generate random 32 bytes for private key
- var privKey [32]byte
- if _, err := rand.Read(privKey[:]); err != nil {
- return nil, err
- }
-
- // Clamp the private key per WireGuard spec
- privKey[0] &= 248
- privKey[31] &= 127
- privKey[31] |= 64
-
- // Derive public key
- pubKey, err := curve25519.X25519(privKey[:], curve25519.Basepoint)
- if err != nil {
- return nil, err
- }
-
- return &KeyPair{
- PrivateKey: base64.StdEncoding.EncodeToString(privKey[:]),
- PublicKey: base64.StdEncoding.EncodeToString(pubKey),
- }, nil
-}
diff --git a/internal/config/keygen_test.go b/internal/config/keygen_test.go
deleted file mode 100644
index 1c90920..0000000
--- a/internal/config/keygen_test.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package config
-
-import (
- "encoding/base64"
- "testing"
-)
-
-func TestGenerateKeyPair(t *testing.T) {
- kp, err := GenerateKeyPair()
- if err != nil {
- t.Fatalf("GenerateKeyPair failed: %v", err)
- }
- if kp.PrivateKey == "" || kp.PublicKey == "" {
- t.Fatal("keys should not be empty")
- }
-
- // Verify Base64 + 32 bytes
- privBytes, err := base64.StdEncoding.DecodeString(kp.PrivateKey)
- if err != nil || len(privBytes) != 32 {
- t.Errorf("private key invalid: len=%d err=%v", len(privBytes), err)
- }
- pubBytes, err := base64.StdEncoding.DecodeString(kp.PublicKey)
- if err != nil || len(pubBytes) != 32 {
- t.Errorf("public key invalid: len=%d err=%v", len(pubBytes), err)
- }
-
- // Verify keys validate with our validator
- if !isValidWireGuardKey(kp.PrivateKey) {
- t.Error("generated private key fails validation")
- }
- if !isValidWireGuardKey(kp.PublicKey) {
- t.Error("generated public key fails validation")
- }
-}
-
-func TestGenerateKeyPairUniqueness(t *testing.T) {
- kp1, _ := GenerateKeyPair()
- kp2, _ := GenerateKeyPair()
- if kp1.PrivateKey == kp2.PrivateKey {
- t.Error("two generated keys should not be identical")
- }
-}
diff --git a/internal/diag/cidr.go b/internal/diag/cidr.go
deleted file mode 100644
index 58cd9c7..0000000
--- a/internal/diag/cidr.go
+++ /dev/null
@@ -1,98 +0,0 @@
-// Package diag provides network diagnostic tools.
-package diag
-
-import (
- "encoding/binary"
- "fmt"
- "math"
- "math/big"
- "net"
-)
-
-// CIDRInfo describes a CIDR block.
-type CIDRInfo struct {
- CIDR string `json:"cidr"`
- Network string `json:"network"`
- Broadcast string `json:"broadcast"`
- FirstHost string `json:"first_host"`
- LastHost string `json:"last_host"`
- TotalHosts int64 `json:"total_hosts"`
- Netmask string `json:"netmask"`
- PrefixLen int `json:"prefix_len"`
-}
-
-// CalculateCIDR computes network details for a CIDR string.
-func CalculateCIDR(cidr string) (*CIDRInfo, error) {
- ip, ipNet, err := net.ParseCIDR(cidr)
- if err != nil {
- return nil, fmt.Errorf("invalid CIDR: %w", err)
- }
-
- ones, bits := ipNet.Mask.Size()
- isIPv4 := bits == 32
-
- info := &CIDRInfo{
- CIDR: cidr,
- Network: ipNet.IP.String(),
- Netmask: net.IP(ipNet.Mask).String(),
- PrefixLen: ones,
- }
-
- if isIPv4 {
- networkIP := ipToUint32(ipNet.IP.To4())
- hostBits := 32 - ones
-
- if hostBits >= 32 {
- // /0 — the shift would overflow uint32. Use the exact
- // IPv4 host count (2^32 − 2 = 4294967294) instead of
- // math.MaxInt64, which was misleadingly large.
- info.TotalHosts = (int64(1) << 32) - 2
- info.FirstHost = "0.0.0.1"
- info.LastHost = "255.255.255.254"
- info.Broadcast = "255.255.255.255"
- } else {
- totalHosts := int64(1) << hostBits
-
- if hostBits > 1 {
- info.TotalHosts = totalHosts - 2 // exclude network + broadcast
- info.FirstHost = uint32ToIP(networkIP + 1).String()
- info.LastHost = uint32ToIP(networkIP + uint32(totalHosts) - 2).String()
- info.Broadcast = uint32ToIP(networkIP + uint32(totalHosts) - 1).String()
- } else if hostBits == 1 {
- info.TotalHosts = 2
- info.FirstHost = ipNet.IP.String()
- info.LastHost = uint32ToIP(networkIP + 1).String()
- info.Broadcast = info.LastHost
- } else {
- info.TotalHosts = 1
- info.FirstHost = ip.String()
- info.LastHost = ip.String()
- info.Broadcast = ip.String()
- }
- }
- } else {
- // IPv6 simplified
- hostBits := 128 - ones
- total := new(big.Int).Lsh(big.NewInt(1), uint(hostBits))
- if total.IsInt64() {
- info.TotalHosts = total.Int64()
- } else {
- info.TotalHosts = math.MaxInt64 // cap to avoid garbage values for large IPv6 subnets
- }
- info.FirstHost = ipNet.IP.String()
- info.LastHost = "..."
- info.Broadcast = "N/A (IPv6)"
- }
-
- return info, nil
-}
-
-func ipToUint32(ip net.IP) uint32 {
- return binary.BigEndian.Uint32(ip)
-}
-
-func uint32ToIP(n uint32) net.IP {
- ip := make(net.IP, 4)
- binary.BigEndian.PutUint32(ip, n)
- return ip
-}
diff --git a/internal/diag/cidr_test.go b/internal/diag/cidr_test.go
deleted file mode 100644
index 2ccf039..0000000
--- a/internal/diag/cidr_test.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package diag
-
-import "testing"
-
-func TestCalculateCIDR24(t *testing.T) {
- info, err := CalculateCIDR("192.168.1.0/24")
- if err != nil {
- t.Fatalf("error: %v", err)
- }
- if info.Network != "192.168.1.0" {
- t.Errorf("network: %s", info.Network)
- }
- if info.Broadcast != "192.168.1.255" {
- t.Errorf("broadcast: %s", info.Broadcast)
- }
- if info.FirstHost != "192.168.1.1" {
- t.Errorf("first host: %s", info.FirstHost)
- }
- if info.LastHost != "192.168.1.254" {
- t.Errorf("last host: %s", info.LastHost)
- }
- if info.TotalHosts != 254 {
- t.Errorf("total hosts: %d", info.TotalHosts)
- }
-}
-
-func TestCalculateCIDR32(t *testing.T) {
- info, err := CalculateCIDR("10.0.0.1/32")
- if err != nil {
- t.Fatalf("error: %v", err)
- }
- if info.TotalHosts != 1 {
- t.Errorf("expected 1 host, got %d", info.TotalHosts)
- }
-}
-
-func TestCalculateCIDR16(t *testing.T) {
- info, err := CalculateCIDR("172.16.0.0/16")
- if err != nil {
- t.Fatalf("error: %v", err)
- }
- if info.TotalHosts != 65534 {
- t.Errorf("expected 65534 hosts, got %d", info.TotalHosts)
- }
-}
-
-func TestCalculateCIDRInvalid(t *testing.T) {
- _, err := CalculateCIDR("not-a-cidr")
- if err == nil {
- t.Error("expected error for invalid CIDR")
- }
-}
diff --git a/internal/diag/ping.go b/internal/diag/ping.go
index d387d1e..33278d6 100644
--- a/internal/diag/ping.go
+++ b/internal/diag/ping.go
@@ -64,8 +64,14 @@ func PingEndpointContext(ctx context.Context, endpoint string) *PingResult {
switch runtime.GOOS {
case "windows":
cmd = exec.CommandContext(ctx, "ping", "-n", "3", "-w", "3000", ip)
+ case "darwin":
+ // -W is per-reply wait in MILLISECONDS on macOS (BSD ping),
+ // unlike Linux where it's seconds. "-W 3" silently meant 3ms.
+ cmd = exec.CommandContext(ctx, "ping", "-c", "3", "-W", "3000", ip)
default:
cmd = exec.CommandContext(ctx, "ping", "-c", "3", "-W", "3", ip)
+ }
+ if runtime.GOOS != "windows" {
// Force canonical output so the parsers below aren't at the mercy
// of the user's locale. No Windows equivalent: ping.exe follows
// the system MUI language regardless of environment (see the
@@ -74,32 +80,33 @@ func PingEndpointContext(ctx context.Context, endpoint string) *PingResult {
}
sysexec.Hide(cmd)
- start := time.Now()
out, err := cmd.CombinedOutput()
- elapsed := time.Since(start)
if err != nil {
result.Error = "Host unreachable"
return result
}
- result.Reachable = true
-
// Parse average latency from ping output
latency := parsePingLatency(string(out))
if latency > 0 {
+ result.Reachable = true
result.LatencyMs = latency
- } else {
- // Fallback: parse individual round-trip times from ping lines
- // (e.g. "time=12.3 ms") and average them, which is more accurate
- // than dividing the total wall-clock elapsed time.
- if avg := parseIndividualPingTimes(string(out)); avg > 0 {
- result.LatencyMs = avg
- } else {
- result.LatencyMs = float64(elapsed.Milliseconds()) / 3
- }
+ return result
}
-
+ // Fallback: parse individual round-trip times from ping lines
+ // (e.g. "time=12.3 ms") and average them.
+ if avg := parseIndividualPingTimes(string(out)); avg > 0 {
+ result.Reachable = true
+ result.LatencyMs = avg
+ return result
+ }
+ // No RTT token anywhere in the output. This happens for router-sourced
+ // "Destination host unreachable" replies, which ping.exe can exit 0
+ // for — the host was never actually reached. The previous wall-clock/3
+ // estimate fabricated ~667ms+RTT latencies here (issue #32); report
+ // unreachable instead and let the UI render "—".
+ result.Error = "No reply times in ping output"
return result
}
diff --git a/internal/diag/ping_test.go b/internal/diag/ping_test.go
index ea5a496..eb20578 100644
--- a/internal/diag/ping_test.go
+++ b/internal/diag/ping_test.go
@@ -80,3 +80,21 @@ func TestParseIndividualPingTimes_Empty(t *testing.T) {
t.Errorf("got %.3f, want 0", got)
}
}
+
+// Router-sourced "Destination host unreachable" replies carry no RTT token,
+// yet ping.exe can exit 0 for them. Both parsers must return 0 so Ping
+// reports unreachable instead of fabricating a latency (issue #32).
+func TestParsers_DestinationHostUnreachable(t *testing.T) {
+ out := "Pinging 10.0.0.7 with 32 bytes of data:\r\n" +
+ "Reply from 192.168.1.1: Destination host unreachable.\r\n" +
+ "Reply from 192.168.1.1: Destination host unreachable.\r\n" +
+ "Reply from 192.168.1.1: Destination host unreachable.\r\n" +
+ "Ping statistics for 10.0.0.7:\r\n" +
+ " Packets: Sent = 3, Received = 3, Lost = 0 (0% loss),\r\n"
+ if got := parsePingLatency(out); got != 0 {
+ t.Errorf("parsePingLatency: got %.3f, want 0", got)
+ }
+ if got := parseIndividualPingTimes(out); got != 0 {
+ t.Errorf("parseIndividualPingTimes: got %.3f, want 0", got)
+ }
+}
diff --git a/internal/diag/speed.go b/internal/diag/speed.go
deleted file mode 100644
index d77cf4f..0000000
--- a/internal/diag/speed.go
+++ /dev/null
@@ -1,103 +0,0 @@
-package diag
-
-import (
- "context"
- "fmt"
- "io"
- "net/http"
- "time"
-)
-
-// SpeedTestResult holds download/upload speed test results.
-type SpeedTestResult struct {
- DownloadMbps float64 `json:"download_mbps"`
- UploadMbps float64 `json:"upload_mbps"`
- LatencyMs float64 `json:"latency_ms"`
- Error string `json:"error,omitempty"`
- // Truncated is set when the download hit the time cap before the
- // full sample arrived. DownloadMbps is still populated with the
- // partial-stream throughput (useful as a lower bound), but the GUI
- // should label it as "≥X Mbps (incomplete)" rather than a precise
- // measurement.
- Truncated bool `json:"truncated,omitempty"`
-}
-
-// RunSpeedTest performs a simple download speed test.
-// Uses a public HTTP endpoint to measure throughput. The caller
-// passes a context so the GUI can cancel mid-test (e.g. user closes
-// the diagnostics tab) without leaving a 10MB download draining in
-// the background.
-func RunSpeedTest(ctx context.Context) *SpeedTestResult {
- result := &SpeedTestResult{}
-
- // Measure latency first. Bound the HEAD with its own sub-timeout:
- // http.DefaultClient has no client-level timeout, so if the caller
- // passed a context without a deadline (e.g. context.Background) a
- // black-holed network would hang this HEAD indefinitely, before the
- // download's own 60s cap is ever reached.
- start := time.Now()
- headCtx, headCancel := context.WithTimeout(ctx, 10*time.Second)
- defer headCancel()
- headReq, err := http.NewRequestWithContext(headCtx, http.MethodHead, "https://www.google.com", nil)
- if err != nil {
- result.Error = fmt.Sprintf("connectivity check setup: %v", err)
- return result
- }
- resp, err := http.DefaultClient.Do(headReq)
- if err != nil {
- result.Error = fmt.Sprintf("connectivity check failed: %v", err)
- return result
- }
- resp.Body.Close()
- result.LatencyMs = float64(time.Since(start).Milliseconds())
-
- // Download test. We pick a 10 MB target on the assumption of
- // "broadband" links (≥10 Mbps → ≤8 s). On an LTE-grade 1 Mbps link
- // that would take 80 s, far past any reasonable diagnostic budget.
- // Strategy: ask for 10 MB but cap the read at 60 seconds AND
- // 10 MB whichever comes first. If we hit the time cap we report
- // the partial throughput instead of timing-out with no result.
- const targetBytes = 10_000_000
- const maxDuration = 60 * time.Second
- dlCtx, cancel := context.WithTimeout(ctx, maxDuration)
- defer cancel()
- testURL := "https://speed.cloudflare.com/__down?bytes=10000000"
- dlReq, err := http.NewRequestWithContext(dlCtx, http.MethodGet, testURL, nil)
- if err != nil {
- result.Error = fmt.Sprintf("download test setup: %v", err)
- return result
- }
-
- start = time.Now()
- dlResp, err := http.DefaultClient.Do(dlReq)
- if err != nil {
- result.Error = fmt.Sprintf("download test failed: %v", err)
- return result
- }
- defer dlResp.Body.Close()
-
- // LimitReader prevents pathological misconfigured servers from
- // streaming forever; ctx already provides the time cap.
- bytes, copyErr := io.Copy(io.Discard, io.LimitReader(dlResp.Body, targetBytes))
- elapsed := time.Since(start).Seconds()
-
- // Distinguish "completed download" from "timed out mid-stream".
- // Truncated = true when ctx deadline expired AND we got partial
- // data — the GUI uses this to label the result as a lower bound
- // rather than a precise measurement.
- if dlCtx.Err() == context.DeadlineExceeded && bytes < targetBytes {
- result.Truncated = true
- } else if copyErr != nil && copyErr != io.EOF {
- // Real I/O error (connection reset, etc.) — surface it.
- result.Error = fmt.Sprintf("download stream error: %v", copyErr)
- }
-
- if elapsed > 0 && bytes > 0 {
- result.DownloadMbps = float64(bytes) * 8 / elapsed / 1_000_000
- }
-
- // Upload test skipped for simplicity (would need a server to accept data)
- result.UploadMbps = 0
-
- return result
-}
diff --git a/internal/elevate/sid_other.go b/internal/elevate/sid_other.go
new file mode 100644
index 0000000..55fb933
--- /dev/null
+++ b/internal/elevate/sid_other.go
@@ -0,0 +1,7 @@
+//go:build !windows
+
+package elevate
+
+// CurrentUserSID is Windows-only; Unix platforms identify the socket
+// owner by UID instead.
+func CurrentUserSID() string { return "" }
diff --git a/internal/elevate/sid_windows.go b/internal/elevate/sid_windows.go
new file mode 100644
index 0000000..4806427
--- /dev/null
+++ b/internal/elevate/sid_windows.go
@@ -0,0 +1,18 @@
+//go:build windows
+
+package elevate
+
+import "golang.org/x/sys/windows"
+
+// CurrentUserSID returns the current process token's user SID in string
+// form (S-1-5-21-…). "" on failure. Note os.Getuid() is -1 on Windows —
+// the SID is the only usable owner identity, which is why the helper's
+// pipe ACL and peer checks are keyed on it rather than a UID.
+func CurrentUserSID() string {
+ tok := windows.GetCurrentProcessToken()
+ u, err := tok.GetTokenUser()
+ if err != nil || u == nil || u.User.Sid == nil {
+ return ""
+ }
+ return u.User.Sid.String()
+}
diff --git a/internal/elevate/spawn.go b/internal/elevate/spawn.go
index 0196c94..9ad7619 100644
--- a/internal/elevate/spawn.go
+++ b/internal/elevate/spawn.go
@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
+ "regexp"
"strings"
)
@@ -15,6 +16,11 @@ type Args struct {
SocketPath string
// SocketUID is the UID to chown the socket to (Unix only). On Windows, 0.
SocketUID int
+ // SocketSID is the spawning user's Windows SID (S-1-5-21-…). The
+ // helper scopes the pipe ACL and per-connection peer checks to it,
+ // replacing the every-interactive-user IU grant (issue #20). Empty
+ // on Unix and on Windows builds that fail to read the token.
+ SocketSID string
// DataDir for crash recovery state
DataDir string
// ForceReinstall skips the "already running" socket check and
@@ -42,9 +48,20 @@ func ValidateArgs(a Args) error {
if err := validateSpawnPath("DataDir", a.DataDir); err != nil {
return err
}
+ if a.SocketSID != "" && !sidPattern.MatchString(a.SocketSID) {
+ // The SID travels through a PowerShell argument list and is later
+ // interpolated into a pipe security descriptor — refuse anything
+ // that isn't a plain S-1-… SID string.
+ return fmt.Errorf("SocketSID is not a valid SID string: %q", a.SocketSID)
+ }
return nil
}
+// sidPattern matches SDDL SID strings like S-1-5-21-…-1001. Windows also
+// accepts two-letter aliases (BA, SY) in SDDL, but we only ever pass full
+// numeric SIDs read from the process token.
+var sidPattern = regexp.MustCompile(`^S-1-\d+(-\d+)+$`)
+
// validateSpawnPath rejects paths that:
// - are not absolute (relative paths could resolve unpredictably under
// the privileged helper's CWD),
diff --git a/internal/elevate/spawn_linux.go b/internal/elevate/spawn_linux.go
index 31a3de3..95bb348 100644
--- a/internal/elevate/spawn_linux.go
+++ b/internal/elevate/spawn_linux.go
@@ -37,7 +37,17 @@ func SpawnHelper(ctx context.Context, args Args) error {
// Put the helper in its own process group so it survives Ctrl+C on the
// parent terminal (macOS version uses `& disown` for the same purpose).
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
- return cmd.Start() // background
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+ // Reap pkexec when it exits — without a Wait every spawn attempt (three
+ // per failed startup, plus one per health-monitor recovery) leaves a
+ // zombie parented to the GUI for the life of the process. Note Start()
+ // succeeding says nothing about authorization: pkexec exits non-zero
+ // AFTER the user dismisses the polkit dialog, so callers must treat
+ // readiness-poll timeout, not Start() error, as the failure signal.
+ go func() { _ = cmd.Wait() }()
+ return nil
}
// PlistNeedsReinstall is a no-op on Linux — there is no LaunchDaemon plist.
diff --git a/internal/elevate/spawn_test.go b/internal/elevate/spawn_test.go
new file mode 100644
index 0000000..2811be2
--- /dev/null
+++ b/internal/elevate/spawn_test.go
@@ -0,0 +1,32 @@
+package elevate
+
+import "testing"
+
+func TestValidateArgsSocketSID(t *testing.T) {
+ base := Args{SocketPath: "/var/run/wireguide/wireguide.sock", DataDir: "/var/lib/wireguide"}
+
+ valid := base
+ valid.SocketSID = "S-1-5-21-3623811015-3361044348-30300820-1013"
+ if err := ValidateArgs(valid); err != nil {
+ t.Errorf("valid SID rejected: %v", err)
+ }
+
+ empty := base // empty SID is fine (Unix / fallback)
+ if err := ValidateArgs(empty); err != nil {
+ t.Errorf("empty SID rejected: %v", err)
+ }
+
+ for _, bad := range []string{
+ "IU", // SDDL alias — we only pass numeric SIDs
+ "S-1-5-21-abc", // non-numeric component
+ "S-1", // too short
+ "S-1-5-21-1013;X", // injection attempt into the SDDL/argv
+ "S-1-5-21-1013')", // PowerShell breakout attempt
+ } {
+ inv := base
+ inv.SocketSID = bad
+ if err := ValidateArgs(inv); err == nil {
+ t.Errorf("invalid SID %q accepted", bad)
+ }
+ }
+}
diff --git a/internal/elevate/spawn_windows.go b/internal/elevate/spawn_windows.go
index 34f565e..24fd3e5 100644
--- a/internal/elevate/spawn_windows.go
+++ b/internal/elevate/spawn_windows.go
@@ -28,6 +28,12 @@ func SpawnHelper(ctx context.Context, args Args) error {
`'--helper','--socket=%s','--data-dir=%s'`,
psEscape(args.SocketPath), psEscape(args.DataDir),
)
+ if args.SocketSID != "" {
+ // ValidateArgs vetted the SID format; the helper scopes the pipe
+ // ACL and peer checks to this SID instead of all interactive
+ // users (issue #20).
+ argList += fmt.Sprintf(`,'--owner-sid=%s'`, psEscape(args.SocketSID))
+ }
ps := fmt.Sprintf(
`Start-Process '%s' -ArgumentList %s -Verb RunAs -WindowStyle Hidden`,
psEscape(exe), argList,
diff --git a/internal/gui/config_watcher.go b/internal/gui/config_watcher.go
index 0a4f607..9f6b941 100644
--- a/internal/gui/config_watcher.go
+++ b/internal/gui/config_watcher.go
@@ -18,14 +18,16 @@ import (
// - config.json → "config_changed" (settings incl. automation rules)
// - the tunnels dir listing → "tunnels_changed" (import / delete / rename)
//
-// A 1 s mtime/listing poll is used (fsnotify isn't a dependency); the
-// latency is imperceptible for these and the cost is a couple of stat
-// calls per second. Reacting to the GUI's own writes is harmless — the
-// frontend re-reads and re-applies the same values idempotently.
+// A 3 s mtime/listing poll is used (fsnotify isn't a dependency); the
+// latency is imperceptible for catching CLI-side edits, and it runs for
+// the whole GUI lifetime including while hidden in the tray — 1 s bought
+// nothing but 3× the stat+readdir churn. Reacting to the GUI's own
+// writes is harmless — the frontend re-reads and re-applies the same
+// values idempotently.
func startConfigWatcher(app *application.App, configPath, tunnelsDir string, done <-chan struct{}, wg *sync.WaitGroup) {
go func() {
defer wg.Done()
- ticker := time.NewTicker(1 * time.Second)
+ ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
fileMtime := func(p string) time.Time {
diff --git a/internal/gui/dock_darwin.go b/internal/gui/dock_darwin.go
index 8a23d54..f4bd12d 100644
--- a/internal/gui/dock_darwin.go
+++ b/internal/gui/dock_darwin.go
@@ -34,7 +34,12 @@ func showDock() {
go func() {
for i := 0; i < 10; i++ {
time.Sleep(200 * time.Millisecond)
- if dockWindow == nil {
+ // dockWindow is assigned once and never cleared, so the nil
+ // check alone can't stop this loop during teardown. Show() on
+ // a destroyed Wails window RE-RUNS window creation, so a retry
+ // landing mid-quit would resurrect the window or deadlock in
+ // InvokeSync — bail out once quit has been initiated.
+ if dockWindow == nil || appQuitting.Load() {
return
}
dockWindow.Show()
diff --git a/internal/gui/dock_other.go b/internal/gui/dock_other.go
index 4fd6fb1..c058d27 100644
--- a/internal/gui/dock_other.go
+++ b/internal/gui/dock_other.go
@@ -2,10 +2,22 @@
package gui
-import "github.com/wailsapp/wails/v3/pkg/application"
+import (
+ "runtime"
+ "sync"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
var dockWindow *application.WebviewWindow
+// showDock is reachable concurrently on Linux — StatusNotifier Activate and
+// dbusmenu events each arrive on their own godbus goroutine, and Wails runs
+// menu callbacks on fresh goroutines. The frameless toggle below must not
+// interleave between two callers or the window can map undecorated with no
+// recovery (GTK caches the decorated hint), so the whole show is serialized.
+var showDockMu sync.Mutex
+
// showDock brings back the main window after close-to-tray. Unlike macOS
// there is no dock icon / activation policy to juggle (and no async retry
// dance) — un-minimise + Show + Focus on the window is the whole job.
@@ -13,12 +25,23 @@ var dockWindow *application.WebviewWindow
// window hidden while minimised would otherwise reappear only in the
// taskbar, still collapsed.
func showDock() {
+ showDockMu.Lock()
+ defer showDockMu.Unlock()
if dockWindow == nil {
return
}
if dockWindow.IsMinimised() {
dockWindow.Restore()
}
+ if runtime.GOOS == "linux" {
+ // labwc/XWayland may forget server-side decorations when a hidden GTK
+ // window is mapped again. Merely setting decorated=true is ineffective
+ // because GTK already caches that value and sends no new WM hint. Toggle
+ // it while the window is hidden so the next map is unambiguously
+ // decorated, without exposing an intermediate frameless frame.
+ dockWindow.SetFrameless(true)
+ dockWindow.SetFrameless(false)
+ }
dockWindow.Show()
dockWindow.Focus()
}
diff --git a/internal/gui/gui.go b/internal/gui/gui.go
index 992e4a5..f4d33f8 100644
--- a/internal/gui/gui.go
+++ b/internal/gui/gui.go
@@ -191,7 +191,16 @@ func Run(assetsHandler http.Handler, dataDir string) error {
// listener races with Wails' listener — the window gets destroyed despite
// Cancel. Hooks run sequentially BEFORE listeners, so Cancel() here
// reliably prevents Wails' default close/destroy behavior.
- win.RegisterHook(events.Common.WindowClosing, func(event *application.WindowEvent) {
+ // Linux must intercept the native delete event itself. Wails maps
+ // Linux.WindowDeleteEvent to Common.WindowClosing asynchronously; hooking
+ // only the mapped event allows the default Common.WindowClosing listener to
+ // destroy the window before close-to-tray can cancel it. Windows and macOS
+ // expose a cancellable Common.WindowClosing event directly.
+ closingEvent := events.Common.WindowClosing
+ if runtime.GOOS == "linux" {
+ closingEvent = events.Linux.WindowDeleteEvent
+ }
+ win.RegisterHook(closingEvent, func(event *application.WindowEvent) {
event.Cancel()
win.Hide()
hideDock()
@@ -219,18 +228,20 @@ func Run(assetsHandler http.Handler, dataDir string) error {
tray.SetIcon(trayOffIconDark)
} else {
tray.SetLabel("WireGuide")
- // Windows also needs an explicit SetIcon at init or Wails falls
- // back to the embedded white-W template — setIconState only
- // runs on connect/disconnect transitions, so a fresh launch
- // with no tunnel previously never showed our rounded icon.
- if runtime.GOOS == "windows" && len(trayOffIconWindows) > 0 {
+ // Windows and Linux need an explicit icon at startup. Without it,
+ // Linux StatusNotifier hosts display their own fallback icon and
+ // Windows falls back to Wails' embedded template. setIconState only
+ // runs on connection transitions, so it cannot initialise the icon.
+ if (runtime.GOOS == "windows" || runtime.GOOS == "linux") && len(trayOffIconWindows) > 0 {
tray.SetIcon(trayOffIconWindows)
}
- // Windows convention: left-click on a tray icon is the primary
- // action — show the main window (WireGuard-for-Windows, Discord,
- // Slack all behave this way); the menu stays on right-click.
+ // Windows convention: left-click is the primary action and shows
+ // the main window; the context menu remains on right-click.
// Registered only here: on macOS any click opens the NSStatusItem
- // menu natively, and an OnClick handler would fight it.
+ // menu natively, and an OnClick handler would fight it. On Linux,
+ // Wails' StatusNotifier fires the click handler for the dbusmenu
+ // "opened" event too, so registering it would raise the window on
+ // every right-click; Linux users get the "Show Window" menu item.
if runtime.GOOS == "windows" {
tray.OnClick(showDock)
}
diff --git a/internal/gui/helper_lifecycle.go b/internal/gui/helper_lifecycle.go
index 782e325..9630a9b 100644
--- a/internal/gui/helper_lifecycle.go
+++ b/internal/gui/helper_lifecycle.go
@@ -24,8 +24,10 @@ func ensureHelper(ctx context.Context, dataDir string) (*ipc.Client, error) {
forceReinstall := false
args := elevate.Args{
SocketPath: addr,
- SocketUID: os.Getuid(),
- DataDir: dataDir,
+ // -1 on Windows — the SID below is the owner identity there.
+ SocketUID: os.Getuid(),
+ SocketSID: elevate.CurrentUserSID(),
+ DataDir: dataDir,
}
// Try an existing helper first (survives GUI restarts).
diff --git a/internal/gui/ssid_reporter.go b/internal/gui/ssid_reporter.go
index 7edf272..9779df1 100644
--- a/internal/gui/ssid_reporter.go
+++ b/internal/gui/ssid_reporter.go
@@ -40,7 +40,9 @@ func startSSIDReporter(clients *ipc.ClientHolder, done <-chan struct{}, wg *sync
// otherwise leave the helper SSID-less until the next roam —
// observed live as `ssid=(none)` right after a dev upgrade.
// Retry briefly until a real value appears.
+ wg.Add(1)
go func() {
+ defer wg.Done()
for _, d := range []time.Duration{2 * time.Second, 5 * time.Second, 15 * time.Second} {
select {
case <-done:
diff --git a/internal/gui/tray.go b/internal/gui/tray.go
index e907261..1e84163 100644
--- a/internal/gui/tray.go
+++ b/internal/gui/tray.go
@@ -32,6 +32,12 @@ import (
// when the observer verifiably reports a light menu bar. We never call
// SetTemplateIcon, so the Wails v3 sticky-isTemplateIcon bug (once set,
// later SetIcon calls render monochrome) is never triggered.
+// appQuitting latches once the user picks Quit from the tray. Package-level
+// (unlike trayManager.quitting, which is per-instance) so the window-show
+// retry goroutines in the dock_* files can observe teardown without holding
+// a trayManager reference.
+var appQuitting atomic.Bool
+
var (
trayOnIconDark []byte // white W + green dot (dark menu bar)
trayOffIconDark []byte // white W, no badge
@@ -485,10 +491,9 @@ func (t *trayManager) setIconState(activeNames []string, handshakeMap map[string
if activeChanged {
onIcon, offIcon := t.macIcons()
- if runtime.GOOS == "windows" && len(trayOnIconWindows) > 0 {
- // Use the rounded-red app-icon variants on Windows so the
- // tray icon actually stands out against a light system-tray
- // background and isn't framed by a white square.
+ if (runtime.GOOS == "windows" || runtime.GOOS == "linux") && len(trayOnIconWindows) > 0 {
+ // Use the rounded-red app-icon variants on Windows and Linux so
+ // the tray icon stands out against light system-tray backgrounds.
onIcon, offIcon = trayOnIconWindows, trayOffIconWindows
}
if anyConnected {
@@ -496,7 +501,7 @@ func (t *trayManager) setIconState(activeNames []string, handshakeMap map[string
tooltip := "WireGuide — " + strings.Join(activeNames, ", ")
t.tray.SetTooltip(tooltip)
} else {
- if runtime.GOOS == "darwin" || (runtime.GOOS == "windows" && len(offIcon) > 0) {
+ if runtime.GOOS == "darwin" || ((runtime.GOOS == "windows" || runtime.GOOS == "linux") && len(offIcon) > 0) {
t.tray.SetIcon(offIcon)
}
t.tray.SetTooltip("WireGuide")
@@ -632,6 +637,7 @@ func (t *trayManager) rebuildMenu() {
// see it and bail. Then stop the timer explicitly to prevent
// the goroutine from running at all in the common case.
t.quitting.Store(true)
+ appQuitting.Store(true)
t.mu.Lock()
if t.rebuildTimer != nil {
t.rebuildTimer.Stop()
diff --git a/internal/helper/events.go b/internal/helper/events.go
index 1736193..2fe1eff 100644
--- a/internal/helper/events.go
+++ b/internal/helper/events.go
@@ -151,6 +151,13 @@ func (h *Helper) statusDTO() ipc.ConnectionStatus {
// and risks chewing through the 30s tick.
func (h *Helper) latencyLoop() {
const tickInterval = 30 * time.Second
+ // With no GUI subscribed, nobody consumes 30s-fresh values — only an
+ // occasional `ctl status` reads the cache. The helper deliberately
+ // outlives the GUI while a tunnel is up (wg-quick semantics), so
+ // without this the headless state would spawn ping subprocesses every
+ // 30s forever. Probes continue at a slow cadence rather than stopping
+ // so ctl status latency stays approximately fresh.
+ const idleTickInterval = 5 * time.Minute
// Sleep briefly on startup so we don't ping immediately during
// helper boot, when the tunnel state is still settling.
select {
@@ -162,10 +169,14 @@ func (h *Helper) latencyLoop() {
for {
h.measureLatencies()
+ interval := tickInterval
+ if !h.server.HasSubscribers() {
+ interval = idleTickInterval
+ }
select {
case <-h.done:
return
- case <-time.After(tickInterval):
+ case <-time.After(interval):
}
}
}
@@ -313,7 +324,10 @@ func (h *Helper) eventLoop() {
}
if !bytes.Equal(lastJSON, currentJSON) {
lastJSON = currentJSON
- h.server.Broadcast(ipc.EventStatus, status)
+ // Pass the bytes the diff already produced — RawMessage
+ // embeds as-is, avoiding a second marshal of the same
+ // struct inside Broadcast at 1 Hz.
+ h.server.Broadcast(ipc.EventStatus, json.RawMessage(currentJSON))
}
}
}
diff --git a/internal/helper/handlers.go b/internal/helper/handlers.go
index 356ec94..6d00a02 100644
--- a/internal/helper/handlers.go
+++ b/internal/helper/handlers.go
@@ -93,11 +93,18 @@ func (h *Helper) handleForceShutdown(params json.RawMessage) (interface{}, error
if err := h.firewall.Cleanup(); err != nil {
slog.Warn("ForceShutdown: firewall.Cleanup failed", "error", err)
}
+ // The utun devices die with this process, but networksetup DNS
+ // overrides persist in SystemConfiguration — restore them or a
+ // helper upgrade while connected leaves tunnel DNS behind
+ // (issue #34). Best-effort under the same deadline.
+ if h.manager != nil {
+ h.manager.RestoreDNSBestEffort()
+ }
}()
select {
case <-done:
- case <-time.After(1 * time.Second):
- slog.Warn("ForceShutdown: firewall.Cleanup timed out; exiting anyway")
+ case <-time.After(3 * time.Second):
+ slog.Warn("ForceShutdown: cleanup timed out; exiting anyway")
}
os.Exit(0)
}()
diff --git a/internal/helper/helper.go b/internal/helper/helper.go
index 45fbbc2..99506c3 100644
--- a/internal/helper/helper.go
+++ b/internal/helper/helper.go
@@ -198,9 +198,11 @@ type Helper struct {
// Run starts the helper listening on addr. Blocks until shutdown.
// ownerUID: UID to chown socket to (Unix only, use -1 on Windows).
+// ownerSID: spawning user's SID (Windows only, "" on Unix) — scopes the
+// pipe ACL and per-connection peer checks to that user (issue #20).
// dataDir: persistent data dir for crash recovery state.
-func Run(addr string, ownerUID int, dataDir string) error {
- listener, err := ipc.Listen(addr, ownerUID)
+func Run(addr string, ownerUID int, ownerSID, dataDir string) error {
+ listener, err := ipc.Listen(addr, ownerUID, ownerSID)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
@@ -215,7 +217,7 @@ func Run(addr string, ownerUID int, dataDir string) error {
manager.SetEndpointProtector(fw)
h := &Helper{
- server: ipc.NewServer(listener, ownerUID),
+ server: ipc.NewServer(listener, ownerUID).WithOwnerSID(ownerSID),
manager: manager,
firewall: fw,
activeCfgs: make(map[string]*domain.WireGuardConfig),
@@ -517,30 +519,52 @@ func (h *Helper) armShutdownTimer(grace time.Duration, reason string) {
active = h.manager.ActiveTunnel()
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
if active != "" {
slog.Info("tunnel is active — helper stays alive (wg-quick semantics)",
"reason", reason, "active_tunnel", active)
+ // A previously armed window (e.g. the startup grace) is obsolete
+ // now that a tunnel is up — stop it rather than leaving it to fire
+ // into a transient not-connected instant later.
+ if h.shutdownTimer != nil {
+ h.shutdownTimer.Stop()
+ h.shutdownTimer = nil
+ }
return
}
slog.Info("no active tunnel — starting shutdown grace window",
"reason", reason, "grace", grace)
- h.mu.Lock()
- defer h.mu.Unlock()
if h.shutdownTimer != nil {
h.shutdownTimer.Stop()
}
- h.shutdownTimer = time.AfterFunc(grace, func() {
+ var t *time.Timer
+ t = time.AfterFunc(grace, func() {
+ // Timer.Stop() cannot cancel a callback that has already started,
+ // so re-check under the lock that we are still the current timer —
+ // otherwise a cancel racing with the fire still shuts the helper
+ // down right after a GUI attached.
+ h.mu.Lock()
+ current := h.shutdownTimer
+ h.mu.Unlock()
+ if current != t {
+ return
+ }
// Double-check at fire time: a tunnel may have been activated between
// timer start and fire (e.g., reconnect monitor brought it back up).
- if t := h.manager.ActiveTunnel(); t != "" {
- slog.Info("shutdown timer fired but tunnel is now active — aborting shutdown",
- "active_tunnel", t)
- return
+ if h.manager != nil {
+ if tn := h.manager.ActiveTunnel(); tn != "" {
+ slog.Info("shutdown timer fired but tunnel is now active — aborting shutdown",
+ "active_tunnel", tn)
+ return
+ }
}
slog.Info("no reconnect within grace window, shutting down")
h.shutdown()
})
+ h.shutdownTimer = t
}
// cancelShutdownTimer aborts a pending grace-window shutdown. Called when the
diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go
index 7fd42ff..209f6a2 100644
--- a/internal/ipc/ipc_test.go
+++ b/internal/ipc/ipc_test.go
@@ -13,7 +13,10 @@ func testSocketPath(t *testing.T) string {
if runtime.GOOS == "windows" {
return `\\.\pipe\wireguide-test-` + t.Name()
}
- return filepath.Join(os.TempDir(), "wireguide-test-"+t.Name()+".sock")
+ // Listen deliberately rejects sockets placed directly in a directory
+ // owned by another UID. /tmp is normally root-owned on Linux, so give
+ // each test a private, test-user-owned parent directory instead.
+ return filepath.Join(t.TempDir(), "wireguide.sock")
}
// registerTestPing registers a Helper.Ping handler that returns the current
@@ -26,7 +29,7 @@ func registerTestPing(s *Server) {
func TestClientServerRPC(t *testing.T) {
addr := testSocketPath(t)
- listener, err := Listen(addr, -1)
+ listener, err := Listen(addr, -1, "")
if err != nil {
t.Fatalf("Listen: %v", err)
}
@@ -62,7 +65,7 @@ func TestClientServerRPC(t *testing.T) {
func TestEventBroadcast(t *testing.T) {
addr := testSocketPath(t)
- listener, err := Listen(addr, -1)
+ listener, err := Listen(addr, -1, "")
if err != nil {
t.Fatalf("Listen: %v", err)
}
@@ -109,7 +112,7 @@ func TestEventBroadcast(t *testing.T) {
func TestMethodNotFound(t *testing.T) {
addr := testSocketPath(t)
- listener, err := Listen(addr, -1)
+ listener, err := Listen(addr, -1, "")
if err != nil {
t.Fatalf("Listen: %v", err)
}
diff --git a/internal/ipc/peercred_unix.go b/internal/ipc/peercred_unix.go
index 397c7e0..4c09875 100644
--- a/internal/ipc/peercred_unix.go
+++ b/internal/ipc/peercred_unix.go
@@ -43,11 +43,13 @@ func getPeerCredFromFD(fd uintptr) (uid uint32, pid int32, err error) {
return getPeerCredPlatform(fd)
}
-// verifyPeerUID checks that the peer's UID matches the expected owner.
-// Returns nil if the check passes or if peer credential retrieval is not
-// supported (fail-open would be worse, but on Unix we always have one of
+// verifyPeer checks that the peer's UID matches the expected owner.
+// expectedSID is Windows-only and ignored on Unix. Returns nil if the
+// check passes or if peer credential retrieval is not supported
+// (fail-open would be worse, but on Unix we always have one of
// SO_PEERCRED or Getpeereid, so this path should not be hit).
-func verifyPeerUID(conn net.Conn, expectedUID int) error {
+func verifyPeer(conn net.Conn, expectedUID int, expectedSID string) error {
+ _ = expectedSID
if expectedUID < 0 {
// No owner restriction requested (e.g. test mode).
return nil
diff --git a/internal/ipc/peercred_windows.go b/internal/ipc/peercred_windows.go
index 14c38a0..b99e5c0 100644
--- a/internal/ipc/peercred_windows.go
+++ b/internal/ipc/peercred_windows.go
@@ -4,28 +4,75 @@ package ipc
import (
"errors"
+ "fmt"
"net"
+
+ "golang.org/x/sys/windows"
)
-// getPeerCredential is a no-op on Windows. Access control is enforced by the
-// SDDL on the named pipe (see transport_windows.go), so peer credential
-// checking is not needed.
+// getPeerCredential is a no-op on Windows — peer identity is checked by
+// SID in verifyPeer below, not by UID.
func getPeerCredential(conn net.Conn) (uid uint32, pid int32, err error) {
return 0, 0, nil
}
-// verifyPeerUID intentionally fails closed when an explicit UID match is
-// requested. The Windows transport relies on the SDDL applied at pipe
-// creation time (see transport_windows.go) to gate access; a per-
-// connection UID check is not implemented here. Returning success
-// unconditionally would silently grant access on any future caller that
-// passes expectedUID >= 0 expecting enforcement, so we surface the gap
-// instead of failing open.
+// verifyPeer checks that the connecting process's token user matches the
+// expected owner SID (issue #20). The pipe's SDDL is the first gate; this
+// is the per-connection second gate, protecting against ACL regressions
+// and any path that loosens the descriptor.
//
-// expectedUID < 0 means "any peer is fine" — we honour that as a no-op.
-func verifyPeerUID(conn net.Conn, expectedUID int) error {
- if expectedUID < 0 {
+// expectedSID == "" means the helper was spawned without --owner-sid
+// (older GUI or manual start): fall back to the historical behaviour —
+// SDDL-only gating when no UID restriction was requested, fail closed if
+// a caller expected UID enforcement (expectedUID >= 0), because Windows
+// has no UID to enforce.
+func verifyPeer(conn net.Conn, expectedUID int, expectedSID string) error {
+ if expectedSID == "" {
+ if expectedUID < 0 {
+ return nil
+ }
+ return errors.New("verifyPeer: no owner SID configured; per-connection UID check not possible on Windows")
+ }
+
+ want, err := windows.StringToSid(expectedSID)
+ if err != nil {
+ return fmt.Errorf("verifyPeer: invalid expected SID %q: %w", expectedSID, err)
+ }
+
+ fdc, ok := conn.(interface{ Fd() uintptr })
+ if !ok {
+ return errors.New("verifyPeer: pipe connection does not expose a handle")
+ }
+ var pid uint32
+ if err := windows.GetNamedPipeClientProcessId(windows.Handle(fdc.Fd()), &pid); err != nil {
+ return fmt.Errorf("verifyPeer: GetNamedPipeClientProcessId: %w", err)
+ }
+ proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
+ if err != nil {
+ return fmt.Errorf("verifyPeer: OpenProcess(%d): %w", pid, err)
+ }
+ defer windows.CloseHandle(proc)
+ var tok windows.Token
+ if err := windows.OpenProcessToken(proc, windows.TOKEN_QUERY, &tok); err != nil {
+ return fmt.Errorf("verifyPeer: OpenProcessToken(%d): %w", pid, err)
+ }
+ defer tok.Close()
+ u, err := tok.GetTokenUser()
+ if err != nil {
+ return fmt.Errorf("verifyPeer: GetTokenUser(%d): %w", pid, err)
+ }
+
+ // The owner's own elevated processes keep the same user SID
+ // (elevation changes group membership, not the user), so an admin
+ // terminal run by the owner still passes. SYSTEM is allowed for
+ // service-context tooling (e.g. the helper health-checking itself).
+ system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
+ if err == nil && u.User.Sid.Equals(system) {
return nil
}
- return errors.New("verifyPeerUID: per-connection UID check not implemented on Windows; rely on pipe SDDL")
+ if !u.User.Sid.Equals(want) {
+ return fmt.Errorf("verifyPeer: pid %d user %s does not match pipe owner %s",
+ pid, u.User.Sid.String(), expectedSID)
+ }
+ return nil
}
diff --git a/internal/ipc/protocol.go b/internal/ipc/protocol.go
index 1cccbe5..9391e66 100644
--- a/internal/ipc/protocol.go
+++ b/internal/ipc/protocol.go
@@ -1,7 +1,10 @@
// Package ipc provides JSON-RPC 2.0 IPC between GUI and helper processes.
package ipc
-import "encoding/json"
+import (
+ "encoding/json"
+ "fmt"
+)
// Protocol version uses simple major.minor semver:
// - MAJOR bumps when fields are RENAMED or REMOVED, or a method's
@@ -21,7 +24,9 @@ const (
// ProtocolVersion is the canonical "major.minor" string used in
// PingResponse.Version. Mismatched majors abort the handshake.
-var ProtocolVersion = "1.0"
+// Derived from the constants above so bumping them cannot silently
+// leave the wire string behind.
+var ProtocolVersion = fmt.Sprintf("%d.%d", ProtocolMajor, ProtocolMinor)
// MajorVersionMatches reports whether two "major.minor" version strings
// share the same MAJOR. A missing dot is treated as MAJOR-only
diff --git a/internal/ipc/server.go b/internal/ipc/server.go
index c99d7e2..062ff30 100644
--- a/internal/ipc/server.go
+++ b/internal/ipc/server.go
@@ -32,6 +32,9 @@ type Server struct {
listener net.Listener
handlers map[string]Handler
ownerUID int // expected peer UID on Unix (-1 to skip check)
+ // ownerSID is the expected peer user SID on Windows ("" to fall back
+ // to SDDL-only gating). Set via WithOwnerSID from helper.Run.
+ ownerSID string
mu sync.Mutex
eventSubs map[*subscriber]struct{} // active event subscribers
@@ -73,6 +76,14 @@ func NewServer(listener net.Listener, ownerUID ...int) *Server {
}
}
+// WithOwnerSID sets the expected peer user SID (Windows). Chainable so
+// helper.Run can construct the server in one expression. Call before
+// Serve — the field is read per-connection without a lock.
+func (s *Server) WithOwnerSID(sid string) *Server {
+ s.ownerSID = sid
+ return s
+}
+
// Handle registers an RPC handler for the given method.
func (s *Server) Handle(method string, h Handler) {
s.mu.Lock()
@@ -249,7 +260,7 @@ func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
// Verify the connecting process belongs to the expected owner.
- if err := verifyPeerUID(conn, s.ownerUID); err != nil {
+ if err := verifyPeer(conn, s.ownerUID, s.ownerSID); err != nil {
slog.Warn("ipc: rejecting connection: peer credential check failed", "error", err)
return
}
diff --git a/internal/ipc/transport_unix.go b/internal/ipc/transport_unix.go
index 6c50596..476af75 100644
--- a/internal/ipc/transport_unix.go
+++ b/internal/ipc/transport_unix.go
@@ -14,7 +14,9 @@ import (
// Listen creates a Unix socket listener at addr.
// If ownerUID >= 0, chowns the socket to that UID and sets mode 0600.
-func Listen(addr string, ownerUID int) (net.Listener, error) {
+// ownerSID is Windows-only and ignored here.
+func Listen(addr string, ownerUID int, ownerSID string) (net.Listener, error) {
+ _ = ownerSID
// Ensure parent directory exists. On macOS the socket lives in
// /var/run/wireguide/ — the helper (root) creates it, and the GUI
// (unprivileged user) needs to traverse it to reach the socket.
diff --git a/internal/ipc/transport_windows.go b/internal/ipc/transport_windows.go
index bbcfb00..58bc082 100644
--- a/internal/ipc/transport_windows.go
+++ b/internal/ipc/transport_windows.go
@@ -12,18 +12,12 @@ import (
"golang.org/x/sys/windows"
)
-// Listen creates a named pipe listener.
-// ownerSID (parameter int is ignored on Windows; use SDDL string) controls ACL.
-func Listen(addr string, ownerUID int) (net.Listener, error) {
- // H13: SYSTEM and Administrators get full control (GA). Interactive Users
- // (IU / S-1-5-4) get read+write only (GRGW) so the unprivileged GUI
- // process can connect to the helper pipe without requiring elevation.
- // IU covers any user who has logged on interactively — this is the
- // minimal group that enables the privilege-separation design.
- sddl := "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"
-
+// Listen creates a named pipe listener. ownerUID is ignored on Windows;
+// ownerSID (the spawning user's SID) scopes the pipe ACL to that user.
+func Listen(addr string, ownerUID int, ownerSID string) (net.Listener, error) {
+ _ = ownerUID
config := &winio.PipeConfig{
- SecurityDescriptor: sddl,
+ SecurityDescriptor: buildPipeSDDL(ownerSID),
MessageMode: false,
InputBufferSize: 65536,
OutputBufferSize: 65536,
@@ -36,6 +30,28 @@ func Listen(addr string, ownerUID int) (net.Listener, error) {
return l, nil
}
+// buildPipeSDDL returns the pipe's security descriptor. SYSTEM and
+// Administrators get full control (GA). The read+write (GRGW) grant that
+// lets the unprivileged GUI connect is scoped to the spawning user's SID
+// — the previous grant to Interactive Users (IU / S-1-5-4) let EVERY
+// logged-on account on a multi-user machine drive a SYSTEM helper:
+// disconnect tunnels, disable the kill switch, forge SSIDs into the
+// automation engine, force shutdown (issue #20).
+//
+// An empty or malformed SID falls back to the historical IU grant so a
+// helper started by an older GUI (no --owner-sid) keeps working; the
+// fallback is logged as a warning at the call site of Run. Validation
+// uses windows.StringToSid — never interpolate an unvalidated string
+// into a security descriptor.
+func buildPipeSDDL(ownerSID string) string {
+ if ownerSID != "" {
+ if _, err := windows.StringToSid(ownerSID); err == nil {
+ return fmt.Sprintf("D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;%s)", ownerSID)
+ }
+ }
+ return "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"
+}
+
// Dial connects to a named pipe and verifies the server is owned by a trusted
// principal (Local System or Built-in Administrators).
func Dial(addr string) (net.Conn, error) {
diff --git a/internal/ipc/types.go b/internal/ipc/types.go
index 8ee6e74..e79e640 100644
--- a/internal/ipc/types.go
+++ b/internal/ipc/types.go
@@ -88,14 +88,6 @@ type ActiveTunnelsResponse struct {
Names []string `json:"names"`
}
-// MultiStatusResponse carries status for every active tunnel plus an
-// aggregate state. The frontend can iterate Tunnels for per-tunnel detail
-// or use the top-level State for a single-tunnel-compatible view.
-type MultiStatusResponse struct {
- State domain.State `json:"state"`
- Tunnels []ConnectionStatus `json:"tunnels"`
-}
-
// BoolResponse wraps a single bool.
type BoolResponse struct {
Value bool `json:"value"`
diff --git a/internal/network/darwin.go b/internal/network/darwin.go
index bd034a7..087f387 100644
--- a/internal/network/darwin.go
+++ b/internal/network/darwin.go
@@ -429,11 +429,12 @@ func (m *DarwinManager) reapply() {
needBypassUpdate := (hasV4 || hasV6) && (gatewayChanged || endpointsChanged)
if !needBypassUpdate {
- // No bypass work needed, but still re-apply DNS — macOS can
- // reassign DNS when switching network services.
+ // No bypass work needed, but DNS may still need attention — macOS
+ // can reassign DNS when switching network services. Verify before
+ // rewriting: this branch runs on every route-table event.
if len(dns) > 0 {
- if err := m.applyDNS(dns); err != nil {
- slog.Warn("reapply: applyDNS failed", "error", err)
+ if err := m.applyDNSIfDrifted(dns); err != nil {
+ slog.Warn("reapply: applyDNSIfDrifted failed", "error", err)
}
}
return
@@ -900,6 +901,20 @@ func (m *DarwinManager) SetDNS(ifaceName string, entries []string) error {
// Push the new DNS to every service in parallel.
m.applyDNSToServices(entries, services)
+ // Commit state BEFORE verification: once applyDNSToServices ran, the
+ // system carries our overrides, and every restore path (Cleanup →
+ // RestoreDNS, crash-recovery SavedDNSSnapshot) keys off dnsActive.
+ // Returning a verification error with dnsActive still false left the
+ // user stuck on tunnel DNS that nothing would ever remove (issue #34
+ // gap: connect rollback no-op'd on !dnsActive).
+ m.mu.Lock()
+ // Defensive copy — callers today build a fresh slice but a future
+ // caller passing cfg.Interface.DNS directly would let reapply() (via
+ // append-and-grow on the slice header) silently read live config.
+ m.lastDNS = append([]string(nil), entries...)
+ m.dnsActive = true
+ m.mu.Unlock()
+
// Verify DNS actually took effect on at least one service. macOS can
// silently fail to apply DNS settings (e.g. permission issues, MDM
// profiles overriding). Without this check the user thinks VPN DNS
@@ -916,14 +931,6 @@ func (m *DarwinManager) SetDNS(ifaceName string, entries []string) error {
// Without this users can keep hitting stale resolutions for several
// minutes — wg-quick does this at the end of its set_dns.
flushDNSCache()
-
- m.mu.Lock()
- // Defensive copy — callers today build a fresh slice but a future
- // caller passing cfg.Interface.DNS directly would let reapply() (via
- // append-and-grow on the slice header) silently read live config.
- m.lastDNS = append([]string(nil), entries...)
- m.dnsActive = true
- m.mu.Unlock()
return nil
}
@@ -932,10 +939,66 @@ func (m *DarwinManager) SetDNS(ifaceName string, entries []string) error {
// present when SetDNS was first called, so they can be properly restored.
func (m *DarwinManager) applyDNS(entries []string) error {
services := getAllNetworkServices()
+ m.captureNewServices(services)
+ m.applyDNSToServices(entries, services)
+ flushDNSCache()
+ return nil
+}
+
+// applyDNSIfDrifted re-applies DNS only to services whose current values
+// differ from the desired set. Route-table events fire on Wi-Fi roams,
+// DHCP renewals, sleep/wake and other VPNs starting; unconditionally
+// rewriting identical DNS on every one of them spawned 10-20 networksetup
+// processes and HUP'd mDNSResponder — wiping the machine-wide resolver
+// cache — per event. Reads are parallel networksetup queries; the resolver
+// flush runs only when at least one service actually needed a rewrite.
+func (m *DarwinManager) applyDNSIfDrifted(entries []string) error {
+ services := getAllNetworkServices()
+ m.captureNewServices(services)
- // M5: Check for new services that weren't in the original savedDNS map.
- // Collect the list of services needing DNS capture under the lock, then
- // release the lock once, do all network calls, and re-lock once to store.
+ servers, search := splitDNSEntries(entries)
+ drifted := make([]bool, len(services))
+ var wg sync.WaitGroup
+ for i, svc := range services {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ cur, err := getCurrentDNS(svc)
+ if err != nil || !stringSetEqual(cur, servers) {
+ // Read failure counts as drift: rewriting is the safe side.
+ drifted[i] = true
+ return
+ }
+ if len(search) > 0 {
+ curSearch, serr := getCurrentSearchDomains(svc)
+ if serr != nil || !stringSetEqual(curSearch, search) {
+ drifted[i] = true
+ }
+ }
+ }()
+ }
+ wg.Wait()
+
+ var need []string
+ for i, svc := range services {
+ if drifted[i] {
+ need = append(need, svc)
+ }
+ }
+ if len(need) == 0 {
+ slog.Debug("reapply: DNS already correct on all services — skipping rewrite")
+ return nil
+ }
+ m.applyDNSToServices(entries, need)
+ flushDNSCache()
+ return nil
+}
+
+// captureNewServices saves the original DNS of network services that
+// appeared after SetDNS was first called, so they can be restored too.
+// Collect the list needing capture under the lock, release it for the
+// network calls, and re-lock once to store.
+func (m *DarwinManager) captureNewServices(services []string) {
var newServices []string
m.mu.Lock()
if m.dnsActive {
@@ -947,41 +1010,36 @@ func (m *DarwinManager) applyDNS(entries []string) error {
}
m.mu.Unlock()
- // Fetch DNS for newly discovered services without holding the lock.
- if len(newServices) > 0 {
- type savedEntry struct {
- svc string
- dns []string
- search []string
- }
- fetched := make([]savedEntry, len(newServices))
- var wg sync.WaitGroup
- for i, svc := range newServices {
- i, svc := i, svc
- wg.Add(1)
- go func() {
- defer wg.Done()
- dns, _ := getCurrentDNS(svc)
- search, _ := getCurrentSearchDomains(svc)
- fetched[i] = savedEntry{svc: svc, dns: dns, search: search}
- }()
- }
- wg.Wait()
+ if len(newServices) == 0 {
+ return
+ }
+ type savedEntry struct {
+ svc string
+ dns []string
+ search []string
+ }
+ fetched := make([]savedEntry, len(newServices))
+ var wg sync.WaitGroup
+ for i, svc := range newServices {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ dns, _ := getCurrentDNS(svc)
+ search, _ := getCurrentSearchDomains(svc)
+ fetched[i] = savedEntry{svc: svc, dns: dns, search: search}
+ }()
+ }
+ wg.Wait()
- m.mu.Lock()
- for _, e := range fetched {
- if _, exists := m.savedDNS[e.svc]; !exists {
- m.savedDNS[e.svc] = e.dns
- m.savedSearch[e.svc] = e.search
- slog.Info("discovered new network service, saving DNS", "service", e.svc)
- }
+ m.mu.Lock()
+ for _, e := range fetched {
+ if _, exists := m.savedDNS[e.svc]; !exists {
+ m.savedDNS[e.svc] = e.dns
+ m.savedSearch[e.svc] = e.search
+ slog.Info("discovered new network service, saving DNS", "service", e.svc)
}
- m.mu.Unlock()
}
-
- m.applyDNSToServices(entries, services)
- flushDNSCache()
- return nil
+ m.mu.Unlock()
}
// applyDNSToServices is the shared hot path used by both SetDNS and applyDNS.
@@ -1224,21 +1282,26 @@ func (m *DarwinManager) RestoreDNS(ifaceName string) error {
return nil
}
-// SavedDNSSnapshot returns the current per-service DNS snapshot for
-// persistence to the crash recovery journal. Thread-safe.
-func (m *DarwinManager) SavedDNSSnapshot() map[string][]string {
+// SavedDNSSnapshot returns the current per-service DNS snapshot (servers
+// AND search domains) for persistence to the crash recovery journal.
+// Thread-safe.
+func (m *DarwinManager) SavedDNSSnapshot() DNSSnapshot {
m.mu.Lock()
defer m.mu.Unlock()
if !m.dnsActive || len(m.savedDNS) == 0 {
- return nil
+ return DNSSnapshot{}
+ }
+ snap := DNSSnapshot{
+ Servers: make(map[string][]string, len(m.savedDNS)),
+ Search: make(map[string][]string, len(m.savedSearch)),
}
- snapshot := make(map[string][]string, len(m.savedDNS))
for svc, dns := range m.savedDNS {
- cp := make([]string, len(dns))
- copy(cp, dns)
- snapshot[svc] = cp
+ snap.Servers[svc] = append([]string(nil), dns...)
+ }
+ for svc, search := range m.savedSearch {
+ snap.Search[svc] = append([]string(nil), search...)
}
- return snapshot
+ return snap
}
// RestoreDNSFromSnapshot restores DNS from a persisted pre-modification
@@ -1250,28 +1313,72 @@ func (m *DarwinManager) SavedDNSSnapshot() map[string][]string {
//
// Side effect: clears in-memory dnsActive/savedDNS so a subsequent
// Cleanup() doesn't re-fire RestoreDNS over our manual restore.
-func (m *DarwinManager) RestoreDNSFromSnapshot(preModDNS map[string][]string) error {
+//
+// The restore is COMPLETE (issue #34): it writes both servers and search
+// domains, and it iterates the union of the snapshot's services, this
+// manager's own saved maps, and every service live right now — so a
+// service that appeared mid-session (Ethernet plugged in, iPhone USB) and
+// received tunnel DNS from a reapply is cleaned too. Per-service fallback
+// order: global pre-VPN snapshot → this manager's captured original
+// (covers mid-session services the global snapshot predates) → "Empty".
+func (m *DarwinManager) RestoreDNSFromSnapshot(snap DNSSnapshot) error {
m.mu.Lock()
+ savedDNS := m.savedDNS
+ savedSearch := m.savedSearch
m.dnsActive = false
m.savedDNS = make(map[string][]string)
m.savedSearch = make(map[string][]string)
m.lastDNS = nil
m.mu.Unlock()
+ svcSet := make(map[string]struct{})
+ for svc := range snap.Servers {
+ svcSet[svc] = struct{}{}
+ }
+ for svc := range snap.Search {
+ svcSet[svc] = struct{}{}
+ }
+ for svc := range savedDNS {
+ svcSet[svc] = struct{}{}
+ }
+ for svc := range savedSearch {
+ svcSet[svc] = struct{}{}
+ }
+ for _, svc := range getAllNetworkServices() {
+ svcSet[svc] = struct{}{}
+ }
+
var wg sync.WaitGroup
- for svc, orig := range preModDNS {
- svc, orig := svc, orig
+ for svc := range svcSet {
wg.Add(1)
go func() {
defer wg.Done()
- if len(orig) == 0 {
+ servers, ok := snap.Servers[svc]
+ if !ok {
+ servers = savedDNS[svc]
+ }
+ if len(servers) > 0 {
+ args := append([]string{"-setdnsservers", svc}, servers...)
+ if err := run("networksetup", args...); err != nil {
+ slog.Warn("RestoreDNSFromSnapshot: setting DNS failed", "service", svc, "error", err)
+ }
+ } else {
if err := run("networksetup", "-setdnsservers", svc, "Empty"); err != nil {
slog.Warn("RestoreDNSFromSnapshot: clearing DNS failed", "service", svc, "error", err)
}
- } else {
- args := append([]string{"-setdnsservers", svc}, orig...)
+ }
+ search, ok := snap.Search[svc]
+ if !ok {
+ search = savedSearch[svc]
+ }
+ if len(search) > 0 {
+ args := append([]string{"-setsearchdomains", svc}, search...)
if err := run("networksetup", args...); err != nil {
- slog.Warn("RestoreDNSFromSnapshot: setting DNS failed", "service", svc, "error", err)
+ slog.Warn("RestoreDNSFromSnapshot: setting search domains failed", "service", svc, "error", err)
+ }
+ } else {
+ if err := run("networksetup", "-setsearchdomains", svc, "Empty"); err != nil {
+ slog.Warn("RestoreDNSFromSnapshot: clearing search domains failed", "service", svc, "error", err)
}
}
}()
@@ -1318,7 +1425,9 @@ func (m *DarwinManager) Cleanup(ifaceName string) error {
// --- helpers ---
-func run(name string, args ...string) error {
+// run is a var so DNS-restore tests can intercept networksetup writes
+// instead of mutating the host's real network services.
+var run = func(name string, args ...string) error {
ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
diff --git a/internal/network/dns_restore_darwin_test.go b/internal/network/dns_restore_darwin_test.go
new file mode 100644
index 0000000..9fbf358
--- /dev/null
+++ b/internal/network/dns_restore_darwin_test.go
@@ -0,0 +1,78 @@
+//go:build darwin
+
+package network
+
+import (
+ "strings"
+ "sync"
+ "testing"
+)
+
+// Issue #34 regression: a last-tunnel disconnect must restore search
+// domains, not just DNS servers — and must cover services the pre-VPN
+// snapshot doesn't know about (they appeared mid-session and got tunnel
+// DNS from a reapply).
+func TestRestoreDNSFromSnapshotRestoresSearchDomains(t *testing.T) {
+ var mu sync.Mutex
+ var calls [][]string
+ origRun := run
+ run = func(name string, args ...string) error {
+ mu.Lock()
+ defer mu.Unlock()
+ calls = append(calls, append([]string{name}, args...))
+ return nil
+ }
+ defer func() { run = origRun }()
+
+ m := NewPlatformManager().(*DarwinManager)
+ m.mu.Lock()
+ m.dnsActive = true
+ // Mid-session service the global snapshot below predates: its original
+ // values were captured into the per-manager maps on discovery.
+ m.savedDNS["USB Ethernet"] = []string{"192.168.10.1"}
+ m.savedSearch["USB Ethernet"] = []string{"lan.local"}
+ m.mu.Unlock()
+
+ snap := DNSSnapshot{
+ Servers: map[string][]string{
+ "Wi-Fi": {"1.1.1.1"},
+ "Ethernet": nil, // was DHCP → must be reset to Empty
+ },
+ Search: map[string][]string{
+ "Wi-Fi": {"corp.example.com"},
+ },
+ }
+ if err := m.RestoreDNSFromSnapshot(snap); err != nil {
+ t.Fatalf("RestoreDNSFromSnapshot: %v", err)
+ }
+
+ find := func(sub string) bool {
+ mu.Lock()
+ defer mu.Unlock()
+ for _, c := range calls {
+ if strings.Contains(strings.Join(c, " "), sub) {
+ return true
+ }
+ }
+ return false
+ }
+
+ for _, want := range []string{
+ "-setdnsservers Wi-Fi 1.1.1.1",
+ "-setsearchdomains Wi-Fi corp.example.com",
+ "-setdnsservers Ethernet Empty",
+ "-setsearchdomains Ethernet Empty",
+ "-setdnsservers USB Ethernet 192.168.10.1",
+ "-setsearchdomains USB Ethernet lan.local",
+ } {
+ if !find(want) {
+ t.Errorf("missing expected restore call containing %q\ncalls: %v", want, calls)
+ }
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.dnsActive {
+ t.Error("dnsActive should be false after restore")
+ }
+}
diff --git a/internal/network/interface.go b/internal/network/interface.go
index bb310bf..1af8358 100644
--- a/internal/network/interface.go
+++ b/internal/network/interface.go
@@ -45,19 +45,33 @@ type NetworkManager interface {
Cleanup(ifaceName string) error
}
+// DNSSnapshot carries the pre-VPN per-service DNS state needed for a
+// COMPLETE restore. Search domains must ride along with servers: the
+// previous servers-only snapshot meant the restore path left
+// `networksetup -setsearchdomains` overrides behind forever (issue #34).
+type DNSSnapshot struct {
+ Servers map[string][]string `json:"servers,omitempty"`
+ Search map[string][]string `json:"search,omitempty"`
+}
+
+// Empty reports whether the snapshot holds no state at all.
+func (s DNSSnapshot) Empty() bool {
+ return len(s.Servers) == 0 && len(s.Search) == 0
+}
+
// DNSStateRestorer is an optional interface that allows restoring DNS
// settings from a persisted pre-modification snapshot during crash recovery.
// Unlike RestoreDNS (which needs in-memory state from the same process),
// this uses the snapshot saved to disk, preserving custom user preferences.
type DNSStateRestorer interface {
- RestoreDNSFromSnapshot(preModDNS map[string][]string) error
+ RestoreDNSFromSnapshot(snap DNSSnapshot) error
}
// SavedDNSSnapshot returns the current in-memory DNS snapshot for
// persistence to the crash recovery journal. Platform managers that
// capture per-service DNS state should implement this.
type DNSSnapshotProvider interface {
- SavedDNSSnapshot() map[string][]string
+ SavedDNSSnapshot() DNSSnapshot
}
// RoutingStateRestorer is an optional interface that platform managers may
@@ -78,10 +92,3 @@ type RoutingStateRestorer interface {
type PreCloseCleaner interface {
PreCloseAdapterCleanup(ifaceName string)
}
-
-// OriginalNetworkState captures the pre-tunnel network state for restoration.
-type OriginalNetworkState struct {
- DNSServers []string `json:"dns_servers"`
- DefaultGW string `json:"default_gateway"`
- DefaultIf string `json:"default_interface"`
-}
diff --git a/internal/network/route_iphlpapi_windows.go b/internal/network/route_iphlpapi_windows.go
index f4e2d32..c1caff1 100644
--- a/internal/network/route_iphlpapi_windows.go
+++ b/internal/network/route_iphlpapi_windows.go
@@ -196,27 +196,6 @@ var ErrRouteAlreadyExists = fmt.Errorf("route already exists")
// isn't in the table. Almost always benign during best-effort cleanup.
var ErrRouteNotFound = fmt.Errorf("route not found")
-// VerifyIpForwardRoute reports whether a route matching the given
-// (dest, prefix, ifaceLuid) tuple is currently in the kernel route
-// table. Used by addFullTunnelRoutes as a post-install sanity check
-// when the install API returned success — defends against the rare
-// kernel-accepted-but-invalid race where the row exists in nsi but
-// the dataplane doesn't honor it yet (typical after a wintun adapter
-// has just been created and the BFE hasn't picked up the LUID).
-//
-// `verifyTimeout` and polling are the caller's responsibility; this
-// is a single point-in-time check.
-//
-// For verifying many routes in one go, prefer VerifyIpForwardRoutes:
-// it takes a single GetIpForwardTable2 snapshot instead of one per
-// route, which matters on machines with hundreds of routes (heavily
-// containerised hosts, multiple-VPN setups).
-func VerifyIpForwardRoute(ifaceLuid uint64, dest net.IP, prefixLen uint8) bool {
- want := []routeKey{{ifaceLuid: ifaceLuid, dest: canonicalIP(dest), prefixLen: prefixLen}}
- missing := VerifyIpForwardRoutes(want)
- return len(missing) == 0
-}
-
// routeKey is the tuple VerifyIpForwardRoutes matches on.
type routeKey struct {
ifaceLuid uint64
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
deleted file mode 100644
index d8bb901..0000000
--- a/internal/notify/notify.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package notify
-
-import (
- "log/slog"
- "os/exec"
- "runtime"
- "strings"
-
- "github.com/korjwl1/wireguide/internal/sysexec"
-)
-
-// SendNotification sends an OS-level notification. Best-effort: failures are
-// logged at debug level but never propagated.
-func SendNotification(title, message string) {
- // Strip control characters that would otherwise break the
- // notification subsystem's display:
- // - NUL terminates C strings; osascript/notify-send silently
- // truncate at the first NUL.
- // - \n / \r split notify-send into multiple notifications on
- // some implementations.
- // - Bell/escape sequences can mis-render in toast renderers.
- title = sanitizeNotificationText(title)
- message = sanitizeNotificationText(message)
-
- // If sanitization left both empty (input was pure control chars
- // or whitespace), skip the notification entirely. osascript on
- // macOS happily displays a blank notification card that the user
- // can't dismiss; notify-send and PowerShell handle it but the
- // result is just visual noise.
- if title == "" && message == "" {
- return
- }
-
- var err error
- switch runtime.GOOS {
- case "darwin":
- err = notifyMac(title, message)
- case "linux":
- err = notifyLinux(title, message)
- case "windows":
- err = notifyWindows(title, message)
- }
- if err != nil {
- slog.Debug("notification failed", "error", err)
- }
-}
-
-// sanitizeNotificationText replaces control characters (NUL, BEL, ESC,
-// CR, LF, TAB and other C0/C1 chars) with single spaces, collapsing
-// runs of whitespace. The exec.Command interface itself is shell-safe
-// (no shell invocation), so this is purely a display-correctness fix.
-func sanitizeNotificationText(s string) string {
- if s == "" {
- return ""
- }
- var b strings.Builder
- b.Grow(len(s))
- prevSpace := false
- for _, r := range s {
- // Treat C0 (U+0000..U+001F) and DEL (U+007F) as whitespace.
- // Allow tab/space through as a single space.
- if r < 0x20 || r == 0x7F {
- if !prevSpace {
- b.WriteByte(' ')
- prevSpace = true
- }
- continue
- }
- b.WriteRune(r)
- prevSpace = false
- }
- return strings.TrimSpace(b.String())
-}
-
-func notifyMac(title, message string) error {
- script := `on run argv
-set theMessage to item 1 of argv
-set theTitle to item 2 of argv
-display notification theMessage with title theTitle
-end run`
- return exec.Command("osascript", "-e", script, message, title).Run()
-}
-
-func notifyLinux(title, message string) error {
- return exec.Command("notify-send", title, message, "-a", "WireGuide").Run()
-}
-
-func notifyWindows(title, message string) error {
- // PowerShell toast notification — use single-quoted strings with doubled
- // single quotes to prevent PowerShell injection.
- // Sanitize newlines to prevent multi-line injection into the PS script.
- safeTitle := strings.ReplaceAll(title, "'", "''")
- safeTitle = strings.ReplaceAll(safeTitle, "\n", " ")
- safeTitle = strings.ReplaceAll(safeTitle, "\r", " ")
- safeMsg := strings.ReplaceAll(message, "'", "''")
- safeMsg = strings.ReplaceAll(safeMsg, "\n", " ")
- safeMsg = strings.ReplaceAll(safeMsg, "\r", " ")
- ps := `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
-$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
-$textNodes = $template.GetElementsByTagName("text")
-$textNodes.Item(0).AppendChild($template.CreateTextNode('` + safeTitle + `')) | Out-Null
-$textNodes.Item(1).AppendChild($template.CreateTextNode('` + safeMsg + `')) | Out-Null
-$toast = [Windows.UI.Notifications.ToastNotification]::new($template)
-[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("WireGuide").Show($toast)`
- cmd := exec.Command("powershell", "-Command", ps)
- sysexec.Hide(cmd)
- return cmd.Run()
-}
diff --git a/internal/reconnect/network_linux.go b/internal/reconnect/network_linux.go
index 9d0e0be..5bf624b 100644
--- a/internal/reconnect/network_linux.go
+++ b/internal/reconnect/network_linux.go
@@ -3,29 +3,31 @@
package reconnect
import (
+ "bufio"
+ "fmt"
+ "io"
"log/slog"
+ "os"
+ "strconv"
+ "strings"
"sync"
"syscall"
"golang.org/x/sys/unix"
)
-// linuxNetworkChangeDetector subscribes to the kernel's RTNETLINK multicast
-// groups for route, address, and link transitions on the underlying
-// interfaces. When a relevant event fires we coalesce a burst into a single
-// notification on ChangeChan (500ms debounce). The reconnect monitor then
-// triggers a per-tunnel reconnect, instead of waiting up to 40s for the
-// generic sleep/wake heuristic to notice.
-//
-// We DO NOT use NetworkManager DBus here — that would tie us to NM, which
-// not every distro ships (Alpine/Arch headless). Raw netlink works on any
-// kernel with CONFIG_RTNETLINK (i.e. every modern Linux).
+// linuxNetworkChangeDetector uses RTNETLINK only as a wake-up source. A
+// notification is emitted only when the actual non-tunnel default route
+// changes. Address and link chatter is common on Linux (NetworkManager,
+// mDNS, IPv6 temporary addresses, and our own TUN setup all generate it), so
+// treating every netlink message as an upstream change causes reconnect loops.
type linuxNetworkChangeDetector struct {
- mu sync.Mutex
- fd int
- stopCh chan struct{}
- changeCh chan struct{}
- running bool
+ mu sync.Mutex
+ fd int
+ stopCh chan struct{}
+ changeCh chan struct{}
+ running bool
+ lastSnapshot string
}
func NewNetworkChangeDetector() NetworkChangeDetector {
@@ -40,6 +42,7 @@ func (d *linuxNetworkChangeDetector) Start() {
}
d.stopCh = make(chan struct{})
d.changeCh = make(chan struct{}, 1)
+ d.lastSnapshot = defaultRouteSnapshot()
d.running = true
d.mu.Unlock()
@@ -69,7 +72,7 @@ func (d *linuxNetworkChangeDetector) Start() {
d.fd = fd
d.mu.Unlock()
go d.readLoop()
- slog.Info("netlink network-change detector started")
+ slog.Info("netlink network-change detector started", "default_route", d.lastSnapshot)
}
func (d *linuxNetworkChangeDetector) Stop() {
@@ -89,33 +92,36 @@ func (d *linuxNetworkChangeDetector) Stop() {
close(stop)
}
if fd != 0 {
- // Shutdown unblocks the recvfrom in readLoop.
_ = unix.Shutdown(fd, unix.SHUT_RDWR)
_ = unix.Close(fd)
}
}
-func (d *linuxNetworkChangeDetector) ChangeChan() <-chan struct{} {
- return d.changeCh
+func (d *linuxNetworkChangeDetector) ChangeChan() <-chan struct{} { return d.changeCh }
+
+func (d *linuxNetworkChangeDetector) checkDefaultRoute() {
+ now := defaultRouteSnapshot()
+ d.mu.Lock()
+ previous := d.lastSnapshot
+ if now == previous {
+ d.mu.Unlock()
+ return
+ }
+ d.lastSnapshot = now
+ d.mu.Unlock()
+
+ slog.Info("network primary upstream changed", "previous", previous, "now", now)
+ select {
+ case d.changeCh <- struct{}{}:
+ default:
+ }
}
-// readLoop drains netlink messages. Every message we receive that isn't a
-// trivial NLMSG_DONE/NLMSG_ERROR is treated as "topology changed" — we don't
-// try to filter by message type because any of our subscribed groups
-// firing implies a route/address/link change worth re-checking.
-//
-// Error handling per man netlink(7):
-// - EINTR / EAGAIN / EWOULDBLOCK: transient; keep going.
-// - ENOBUFS: kernel ran out of receive buffer and dropped messages —
-// we may have missed an RTM event. Fire a single "force" signal so
-// the reconnect monitor re-evaluates, then continue reading.
-// - Anything else (EBADF on shutdown, etc.): exit cleanly.
func (d *linuxNetworkChangeDetector) readLoop() {
buf := make([]byte, 8192)
for {
d.mu.Lock()
- running := d.running
- fd := d.fd
+ running, fd := d.running, d.fd
d.mu.Unlock()
if !running {
return
@@ -123,18 +129,13 @@ func (d *linuxNetworkChangeDetector) readLoop() {
n, _, err := unix.Recvfrom(fd, buf, 0)
if err != nil {
switch err {
- // EAGAIN == EWOULDBLOCK on Linux; listing one is enough.
case syscall.EINTR, syscall.EAGAIN:
continue
case syscall.ENOBUFS:
- slog.Warn("netlink ENOBUFS — kernel dropped messages, forcing reconnect check")
- select {
- case d.changeCh <- struct{}{}:
- default:
- }
+ slog.Warn("netlink ENOBUFS; rechecking the default route")
+ d.checkDefaultRoute()
continue
}
- // EBADF / ENOTCONN are expected during shutdown.
d.mu.Lock()
stillRunning := d.running
d.mu.Unlock()
@@ -143,22 +144,93 @@ func (d *linuxNetworkChangeDetector) readLoop() {
}
return
}
- if n <= 0 {
+ if n > 0 {
+ d.checkDefaultRoute()
+ }
+ }
+}
+
+func defaultRouteSnapshot() string {
+ v4, err4 := os.Open("/proc/net/route")
+ if err4 == nil {
+ defer v4.Close()
+ }
+ v6, err6 := os.Open("/proc/net/ipv6_route")
+ if err6 == nil {
+ defer v6.Close()
+ }
+ return routeSnapshot(v4, v6, func(iface string) bool {
+ if iface == "lo" {
+ return true
+ }
+ _, err := os.Stat("/sys/class/net/" + iface + "/tun_flags")
+ return err == nil
+ })
+}
+
+type defaultRoute struct {
+ iface string
+ gateway string
+ metric uint64
+}
+
+func routeSnapshot(v4, v6 io.Reader, isTunnel func(string) bool) string {
+ parts := make([]string, 0, 2)
+ if route, ok := bestIPv4Default(v4, isTunnel); ok {
+ parts = append(parts, fmt.Sprintf("v4:%s:%s:%d", route.iface, route.gateway, route.metric))
+ }
+ if route, ok := bestIPv6Default(v6, isTunnel); ok {
+ parts = append(parts, fmt.Sprintf("v6:%s:%s:%d", route.iface, route.gateway, route.metric))
+ }
+ return strings.Join(parts, "|")
+}
+
+func bestIPv4Default(r io.Reader, isTunnel func(string) bool) (defaultRoute, bool) {
+ var best defaultRoute
+ found := false
+ if r == nil {
+ return best, false
+ }
+ s := bufio.NewScanner(r)
+ for s.Scan() {
+ f := strings.Fields(s.Text())
+ if len(f) < 8 || f[1] != "00000000" || f[7] != "00000000" || isTunnel(f[0]) {
+ continue
+ }
+ flags, err1 := strconv.ParseUint(f[3], 16, 64)
+ metric, err2 := strconv.ParseUint(f[6], 10, 64)
+ if err1 != nil || err2 != nil || flags&unix.RTF_UP == 0 {
continue
}
- // Signal the debouncer non-blockingly.
- select {
- case d.changeCh <- struct{}{}:
- default:
- // Coalesce: a notification is already pending.
+ candidate := defaultRoute{iface: f[0], gateway: f[2], metric: metric}
+ if !found || candidate.metric < best.metric {
+ best, found = candidate, true
}
}
+ return best, found
}
-// Note on coalescing: the readLoop sends directly to changeCh (cap 1)
-// non-blockingly. If multiple RTM messages arrive in quick succession
-// only the first one is delivered until a consumer drains the channel —
-// the cap-1 buffer already implements "edge-triggered, single pending
-// notification" coalescing. The reconnect monitor downstream runs each
-// reconnect under its own backoff, so a separate debouncer goroutine
-// inside this detector adds no value.
+func bestIPv6Default(r io.Reader, isTunnel func(string) bool) (defaultRoute, bool) {
+ var best defaultRoute
+ found := false
+ if r == nil {
+ return best, false
+ }
+ s := bufio.NewScanner(r)
+ for s.Scan() {
+ f := strings.Fields(s.Text())
+ if len(f) < 10 || f[0] != strings.Repeat("0", 32) || f[1] != "00" || f[2] != strings.Repeat("0", 32) || f[3] != "00" || isTunnel(f[9]) {
+ continue
+ }
+ metric, err := strconv.ParseUint(f[5], 16, 64)
+ flags, flagsErr := strconv.ParseUint(f[8], 16, 64)
+ if err != nil || flagsErr != nil || flags&unix.RTF_UP == 0 {
+ continue
+ }
+ candidate := defaultRoute{iface: f[9], gateway: f[4], metric: metric}
+ if !found || candidate.metric < best.metric {
+ best, found = candidate, true
+ }
+ }
+ return best, found
+}
diff --git a/internal/reconnect/network_linux_test.go b/internal/reconnect/network_linux_test.go
new file mode 100644
index 0000000..faf565b
--- /dev/null
+++ b/internal/reconnect/network_linux_test.go
@@ -0,0 +1,43 @@
+//go:build linux
+
+package reconnect
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRouteSnapshotIgnoresNoiseAndTunnelDefaults(t *testing.T) {
+ v4 := `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
+wlan0 00000000 0101A8C0 0003 0 0 600 00000000 0 0 0
+wg-demo 00000000 00000000 0001 0 0 0 00000000 0 0 0
+wlan0 0001A8C0 00000000 0001 0 0 600 00FFFFFF 0 0 0
+`
+ v6 := `00000000000000000000000000000000 00 00000000000000000000000000000000 00 FE800000000000000001000000000001 00000400 00000000 00000000 00000003 wlan0
+00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 00000000 00000000 00000000 00000001 wg-demo
+`
+ isTunnel := func(name string) bool { return name == "wg-demo" }
+ got := routeSnapshot(strings.NewReader(v4), strings.NewReader(v6), isTunnel)
+ want := "v4:wlan0:0101A8C0:600|v6:wlan0:FE800000000000000001000000000001:1024"
+ if got != want {
+ t.Fatalf("routeSnapshot() = %q, want %q", got, want)
+ }
+}
+
+func TestRouteSnapshotChangesOnlyForDefaultRouteState(t *testing.T) {
+ base := `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
+wlan0 00000000 0101A8C0 0003 0 0 600 00000000 0 0 0
+`
+ withAddressRouteNoise := base + "wlan0 0001A8C0 00000000 0001 0 0 600 00FFFFFF 0 0 0\n"
+ changedGateway := strings.Replace(base, "0101A8C0", "FE01A8C0", 1)
+ none := func(string) bool { return false }
+ a := routeSnapshot(strings.NewReader(base), nil, none)
+ b := routeSnapshot(strings.NewReader(withAddressRouteNoise), nil, none)
+ c := routeSnapshot(strings.NewReader(changedGateway), nil, none)
+ if a != b {
+ t.Fatalf("non-default route noise changed snapshot: %q != %q", a, b)
+ }
+ if a == c {
+ t.Fatalf("gateway change did not change snapshot: %q", a)
+ }
+}
diff --git a/internal/storage/rename.go b/internal/storage/rename.go
index f98d6e1..882ba62 100644
--- a/internal/storage/rename.go
+++ b/internal/storage/rename.go
@@ -6,13 +6,6 @@ import (
"runtime"
)
-// atomicRename moves src to dst. On modern Go (1.21+), os.Rename uses
-// MoveFileEx with MOVEFILE_REPLACE_EXISTING on Windows, so it handles
-// overwriting the destination atomically on all platforms.
-func atomicRename(src, dst string) error {
- return os.Rename(src, dst)
-}
-
// atomicRenameDurable renames src→dst and then fsyncs the containing
// directory so the rename's directory entry survives a power loss. The
// per-file fsync in the writers makes the CONTENT durable, but the
diff --git a/internal/tunnel/connect_phases.go b/internal/tunnel/connect_phases.go
index 11313b5..ba38c86 100644
--- a/internal/tunnel/connect_phases.go
+++ b/internal/tunnel/connect_phases.go
@@ -253,10 +253,10 @@ func (m *Manager) connectPhases(ctx context.Context, cfg *domain.WireGuardConfig
// would already include this tunnel's overrides, so we
// deliberately ignore them via CapturePreModDNS's first-write-
// wins guard.
- var preModDNS map[string][]string
+ var preMod network.DNSSnapshot
if provider, ok := netMgr.(network.DNSSnapshotProvider); ok {
- preModDNS = provider.SavedDNSSnapshot()
- m.CapturePreModDNS(preModDNS)
+ preMod = provider.SavedDNSSnapshot()
+ m.CapturePreModDNS(preMod)
}
if err := SaveActiveState(m.dataDir, &ActiveTunnelState{
@@ -266,7 +266,8 @@ func (m *Manager) connectPhases(ctx context.Context, cfg *domain.WireGuardConfig
FullTunnel: fullTunnel,
Table: cfg.Interface.Table,
FwMark: cfg.Interface.FwMark,
- PreModDNS: preModDNS,
+ PreModDNS: preMod.Servers,
+ PreModSearch: preMod.Search,
}); err != nil {
slog.Warn("failed to persist crash recovery state", "error", err)
}
@@ -354,7 +355,7 @@ func (m *Manager) disconnectPhases(cfg *domain.WireGuardConfig, engine *Engine,
// would still hold the previous tunnel's DNS overrides.
// Cleanup's internal RestoreDNS becomes a no-op because
// RestoreDNSFromSnapshot clears dnsActive.
- if pre := m.PreModDNSSnapshot(); pre != nil {
+ if pre, captured := m.PreModDNSSnapshot(); captured {
if r, ok := netMgr.(network.DNSStateRestorer); ok {
if err := r.RestoreDNSFromSnapshot(pre); err != nil {
slog.Warn("RestoreDNSFromSnapshot failed; falling back to per-netMgr restore", "error", err)
diff --git a/internal/tunnel/loop_watchdog_darwin.go b/internal/tunnel/loop_watchdog_darwin.go
index b7a0d7d..e27f5d9 100644
--- a/internal/tunnel/loop_watchdog_darwin.go
+++ b/internal/tunnel/loop_watchdog_darwin.go
@@ -35,11 +35,42 @@ package tunnel
// - 3 × 5 s = 15 s sustained: defeats bursty downloads and brief
// speed-tests, trips on the steady-state loop signature.
+/*
+#include
+#include
+#include
+#include
+#include
+#include
+
+// iface_octets fetches the 64-bit interface byte counters via the IFMIB
+// sysctl — the same source netstat prints. Two approaches that look right
+// are wrong (both verified empirically against netstat on live traffic):
+// - NET_RT_IFLIST2's if_msghdr2.ifm_data.ifi_ibytes WRAPS AT 32 BITS on
+// modern macOS even though the field is declared u_int64_t; only the
+// IFMIB ifmd_data carries true 64-bit counters.
+// - Reading pack(4) structs through cgo's generated Go types misreads
+// fields — cgo lays them out with natural alignment. So the struct
+// access stays here in C, compiled by clang against the real headers.
+static int iface_octets(int ifindex, unsigned long long *ibytes, unsigned long long *obytes) {
+ struct ifmibdata md;
+ size_t len = sizeof(md);
+ int mib[6] = {CTL_NET, PF_LINK, NETLINK_GENERIC, IFMIB_IFDATA, ifindex, IFDATA_GENERAL};
+ if (sysctl(mib, 6, &md, &len, NULL, 0) != 0) {
+ return 0;
+ }
+ *ibytes = md.ifmd_data.ifi_ibytes;
+ *obytes = md.ifmd_data.ifi_obytes;
+ return 1;
+}
+*/
+import "C"
import (
"bufio"
"bytes"
"context"
"log/slog"
+ "net"
"os/exec"
"strconv"
"strings"
@@ -156,12 +187,42 @@ func maxU64(a, b uint64) uint64 {
}
// readInterfaceOctets reads the kernel's input/output byte counters for
-// the named interface via `netstat -ibnI `. The first data row is
-// the AF_LINK (aggregate) entry whose Ibytes/Obytes are the totals
-// across all address families on the interface — the per-address-family
-// rows that follow carry the same totals (they're aliases of the link
-// entry, not partitions), so taking the first data row is correct and
-// avoids double-counting on multihomed interfaces.
+// the named interface. Primary path is a NET_RT_IFLIST2 sysctl — a plain
+// syscall, matching the Windows watchdog's GetIfEntry2 approach — because
+// the previous `netstat -ibnI` implementation fork/exec'd a subprocess
+// every 5 s for the life of every full-tunnel connection (~17k spawns/day,
+// keeping laptops out of deep idle). netstat is kept as a fallback should
+// the sysctl ever fail.
+func readInterfaceOctets(ifaceName string) (uint64, uint64, bool) {
+ if in, out, ok := readInterfaceOctetsSysctl(ifaceName); ok {
+ return in, out, ok
+ }
+ return readInterfaceOctetsNetstat(ifaceName)
+}
+
+// readInterfaceOctetsSysctl queries NET_RT_IFLIST2 filtered by interface
+// index and reads if_data64.ifi_ibytes/ifi_obytes from the RTM_IFINFO2
+// message. The buffer walk and struct access live in the cgo preamble's
+// iface_octets — see the comment there for why the C side must do it.
+func readInterfaceOctetsSysctl(ifaceName string) (uint64, uint64, bool) {
+ ifi, err := net.InterfaceByName(ifaceName)
+ if err != nil {
+ return 0, 0, false
+ }
+ var ibytes, obytes C.ulonglong
+ if C.iface_octets(C.int(ifi.Index), &ibytes, &obytes) == 0 {
+ return 0, 0, false
+ }
+ return uint64(ibytes), uint64(obytes), true
+}
+
+// readInterfaceOctetsNetstat is the subprocess fallback: `netstat -ibnI
+// `. The first data row is the AF_LINK (aggregate) entry whose
+// Ibytes/Obytes are the totals across all address families on the
+// interface — the per-address-family rows that follow carry the same
+// totals (they're aliases of the link entry, not partitions), so taking
+// the first data row is correct and avoids double-counting on multihomed
+// interfaces.
//
// Column positions are resolved from the header row dynamically rather
// than hardcoded. Apple has shipped layout-different netstat versions
@@ -172,7 +233,7 @@ func maxU64(a, b uint64) uint64 {
// LC_ALL=C forces English headers ("Ibytes"/"Obytes") on non-English
// macOS installs, mirroring what the rest of the darwin network code
// does for its netstat parsers.
-func readInterfaceOctets(ifaceName string) (uint64, uint64, bool) {
+func readInterfaceOctetsNetstat(ifaceName string) (uint64, uint64, bool) {
ctx, cancel := context.WithTimeout(context.Background(), netstatCmdTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "netstat", "-ibnI", ifaceName)
diff --git a/internal/tunnel/loop_watchdog_darwin_test.go b/internal/tunnel/loop_watchdog_darwin_test.go
index 5d6e29a..d3938ff 100644
--- a/internal/tunnel/loop_watchdog_darwin_test.go
+++ b/internal/tunnel/loop_watchdog_darwin_test.go
@@ -2,7 +2,10 @@
package tunnel
-import "testing"
+import (
+ "net"
+ "testing"
+)
func TestParseNetstatIB(t *testing.T) {
tests := []struct {
@@ -95,3 +98,35 @@ utun4 1380 - - -
})
}
}
+
+// TestReadInterfaceOctetsSysctlMatchesNetstat cross-checks the primary
+// sysctl reader against the netstat fallback on a live interface. The
+// counters advance between the two reads, so equality is asserted within
+// a tolerance rather than exactly.
+func TestReadInterfaceOctetsSysctlMatchesNetstat(t *testing.T) {
+ const iface = "en0"
+ if _, err := net.InterfaceByName(iface); err != nil {
+ t.Skipf("no %s on this machine: %v", iface, err)
+ }
+ sIn, sOut, sOK := readInterfaceOctetsSysctl(iface)
+ nIn, nOut, nOK := readInterfaceOctetsNetstat(iface)
+ if !sOK {
+ t.Fatal("sysctl reader failed on a live interface")
+ }
+ if !nOK {
+ t.Skip("netstat fallback unavailable; cannot cross-check")
+ }
+ const tolerance = 16 << 20 // 16 MiB of traffic between the two reads
+ diff := func(a, b uint64) uint64 {
+ if a > b {
+ return a - b
+ }
+ return b - a
+ }
+ if diff(sIn, nIn) > tolerance {
+ t.Errorf("ibytes: sysctl=%d netstat=%d differ by more than %d", sIn, nIn, tolerance)
+ }
+ if diff(sOut, nOut) > tolerance {
+ t.Errorf("obytes: sysctl=%d netstat=%d differ by more than %d", sOut, nOut, tolerance)
+ }
+}
diff --git a/internal/tunnel/manager.go b/internal/tunnel/manager.go
index 6c0064d..c8d6334 100644
--- a/internal/tunnel/manager.go
+++ b/internal/tunnel/manager.go
@@ -84,7 +84,7 @@ type Manager struct {
// the original DHCP defaults rather than the per-netMgr savedDNS,
// which for a non-first tunnel would have been the previous tunnel's
// already-applied DNS. Guarded by m.mu.
- globalPreModDNS map[string][]string
+ globalPreModDNS *network.DNSSnapshot
// endpointProtector is the optional always-on loop protection hook
// (Windows full-tunnel only). Set by the helper after construction
diff --git a/internal/tunnel/manager_dns.go b/internal/tunnel/manager_dns.go
index 8d20789..6d8cb6e 100644
--- a/internal/tunnel/manager_dns.go
+++ b/internal/tunnel/manager_dns.go
@@ -1,6 +1,11 @@
package tunnel
-import "github.com/korjwl1/wireguide/internal/domain"
+import (
+ "log/slog"
+
+ "github.com/korjwl1/wireguide/internal/domain"
+ "github.com/korjwl1/wireguide/internal/network"
+)
// AllDNSServers returns the union of DNS servers from all connected tunnels'
// configs. Used to re-apply the combined DNS when a tunnel connects or
@@ -21,36 +26,30 @@ func (m *Manager) AllDNSServers() []string {
// after A, B's savedDNS is A's DNS — so when B disconnects last via
// netMgr_B.Cleanup the user's system would get restored to A's DNS
// instead of the original DHCP defaults.
-func (m *Manager) CapturePreModDNS(snapshot map[string][]string) {
+func (m *Manager) CapturePreModDNS(snapshot network.DNSSnapshot) {
m.mu.Lock()
defer m.mu.Unlock()
- if m.globalPreModDNS != nil || len(snapshot) == 0 {
+ if m.globalPreModDNS != nil || snapshot.Empty() {
return
}
- cp := make(map[string][]string, len(snapshot))
- for k, v := range snapshot {
- c := make([]string, len(v))
- copy(c, v)
- cp[k] = c
+ m.globalPreModDNS = &network.DNSSnapshot{
+ Servers: copyServiceMap(snapshot.Servers),
+ Search: copyServiceMap(snapshot.Search),
}
- m.globalPreModDNS = cp
}
-// PreModDNSSnapshot returns a copy of the captured pre-VPN DNS, or nil
-// if nothing has been captured yet.
-func (m *Manager) PreModDNSSnapshot() map[string][]string {
+// PreModDNSSnapshot returns a copy of the captured pre-VPN DNS state and
+// whether anything has been captured yet.
+func (m *Manager) PreModDNSSnapshot() (network.DNSSnapshot, bool) {
m.mu.Lock()
defer m.mu.Unlock()
if m.globalPreModDNS == nil {
- return nil
- }
- cp := make(map[string][]string, len(m.globalPreModDNS))
- for k, v := range m.globalPreModDNS {
- c := make([]string, len(v))
- copy(c, v)
- cp[k] = c
+ return network.DNSSnapshot{}, false
}
- return cp
+ return network.DNSSnapshot{
+ Servers: copyServiceMap(m.globalPreModDNS.Servers),
+ Search: copyServiceMap(m.globalPreModDNS.Search),
+ }, true
}
// ClearPreModDNS drops the captured snapshot once the last tunnel has
@@ -61,6 +60,46 @@ func (m *Manager) ClearPreModDNS() {
m.mu.Unlock()
}
+// RestoreDNSBestEffort restores the pre-VPN DNS state using the global
+// snapshot and any live tunnel's network manager. Used by ForceShutdown,
+// which exits without tunnel teardown: the utun devices die with the
+// process but networksetup overrides persist in SystemConfiguration
+// (issue #34 gap 4) — without this a helper upgrade while connected left
+// tunnel DNS behind until crash recovery ran.
+func (m *Manager) RestoreDNSBestEffort() {
+ pre, captured := m.PreModDNSSnapshot()
+ if !captured {
+ return
+ }
+ m.mu.Lock()
+ var restorer network.DNSStateRestorer
+ for _, e := range m.tunnels {
+ if r, ok := e.netMgr.(network.DNSStateRestorer); ok {
+ restorer = r
+ break
+ }
+ }
+ m.mu.Unlock()
+ if restorer == nil {
+ return
+ }
+ if err := restorer.RestoreDNSFromSnapshot(pre); err != nil {
+ slog.Warn("RestoreDNSBestEffort failed", "error", err)
+ }
+ m.ClearPreModDNS()
+}
+
+func copyServiceMap(in map[string][]string) map[string][]string {
+ if in == nil {
+ return nil
+ }
+ out := make(map[string][]string, len(in))
+ for k, v := range in {
+ out[k] = append([]string(nil), v...)
+ }
+ return out
+}
+
// allDNSServersLocked is AllDNSServers without the lock — for callers
// that already hold m.mu (e.g. inside the Phase-3 commit of Connect).
// Today no caller needs it, but exposing the locked variant means a
diff --git a/internal/tunnel/recovery.go b/internal/tunnel/recovery.go
index 36da876..38f1af1 100644
--- a/internal/tunnel/recovery.go
+++ b/internal/tunnel/recovery.go
@@ -29,11 +29,17 @@ type ActiveTunnelState struct {
FullTunnel bool `json:"full_tunnel"`
Table string `json:"table,omitempty"`
FwMark string `json:"fwmark,omitempty"`
- // PreModDNS stores the original DNS settings per network service
+ // PreModDNS stores the original DNS servers per network service
// captured BEFORE any modification. Used for precise crash recovery
// instead of the blunt ResetDNSToSystemDefault which loses custom
// user preferences.
PreModDNS map[string][]string `json:"pre_mod_dns,omitempty"`
+ // PreModSearch stores the original search domains per network
+ // service. Absent in journals written before issue #34's fix; the
+ // restore then clears search domains to "Empty" (DHCP defaults),
+ // which is the correct behaviour for the common no-custom-domains
+ // setup and strictly better than leaking tunnel domains.
+ PreModSearch map[string][]string `json:"pre_mod_search,omitempty"`
}
// Legacy single-tunnel state file (kept for backward-compatible migration).
@@ -71,14 +77,6 @@ func ClearActiveState(dataDir string, tunnelName string) error {
return nil
}
-// ClearAllActiveStates removes all per-tunnel state files.
-func ClearAllActiveStates(dataDir string) error {
- dir := filepath.Join(dataDir, tunnelStatesDir)
- if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
- return err
- }
- return nil
-}
// LoadActiveState reads all active tunnel states from the tunnel-states
// directory. Falls back to the legacy single-file format for migration.
@@ -184,7 +182,8 @@ func RecoverFromCrash(dataDir string, fw FirewallCleaner) []string {
// clears everything to DHCP defaults (loses custom user preferences).
if len(state.PreModDNS) > 0 {
if restorer, mok := mgr.(network.DNSStateRestorer); mok {
- if err := restorer.RestoreDNSFromSnapshot(state.PreModDNS); err != nil {
+ snap := network.DNSSnapshot{Servers: state.PreModDNS, Search: state.PreModSearch}
+ if err := restorer.RestoreDNSFromSnapshot(snap); err != nil {
slog.Warn("crash recovery: precise DNS restore failed, falling back to reset", "error", err)
if err := mgr.ResetDNSToSystemDefault(); err != nil {
ok = false
@@ -235,8 +234,8 @@ func RecoverFromCrash(dataDir string, fw FirewallCleaner) []string {
// Clear ONLY the state files whose recovery fully succeeded.
// States that hit any error stay on disk so the next boot has
- // another chance — silently dropping them via ClearAllActiveStates
- // stranded the bug for users.
+ // another chance — silently dropping them all at once stranded
+ // the bug for users.
for _, name := range fullySucceeded {
ClearActiveState(dataDir, name)
}
diff --git a/internal/tunnel/recovery_test.go b/internal/tunnel/recovery_test.go
index 781153d..f68ab6a 100644
--- a/internal/tunnel/recovery_test.go
+++ b/internal/tunnel/recovery_test.go
@@ -190,16 +190,3 @@ func TestRecoverFromCrashNilFirewall(t *testing.T) {
}
}
-func TestClearAllActiveStates(t *testing.T) {
- dir := t.TempDir()
- SaveActiveState(dir, &ActiveTunnelState{TunnelName: "vpn1"})
- SaveActiveState(dir, &ActiveTunnelState{TunnelName: "vpn2"})
-
- if err := ClearAllActiveStates(dir); err != nil {
- t.Fatalf("ClearAllActiveStates failed: %v", err)
- }
-
- if len(LoadActiveState(dir)) != 0 {
- t.Error("all states should be cleared")
- }
-}
diff --git a/internal/update/checker.go b/internal/update/checker.go
index e2c4dea..de2861c 100644
--- a/internal/update/checker.go
+++ b/internal/update/checker.go
@@ -25,7 +25,7 @@ import (
const (
githubRepo = "korjwl1/wireguide"
apiEndpoint = "https://api.github.com/repos/" + githubRepo + "/releases/latest"
- currentVersion = "0.4.2"
+ currentVersion = "0.4.2-dev2"
// minAssetSize is the minimum acceptable size for a release asset.
// A macOS .dmg/.zip containing WireGuide.app is always well over 1 MB;
@@ -238,16 +238,6 @@ func parseReleaseBody(ctx context.Context, resp *http.Response, client *http.Cli
}, nil
}
-// BrewUpgradeCommand returns the shell command a Homebrew user should
-// run to upgrade WireGuide. Returned as a string (not executed) so the
-// UI can show it next to a Copy button — the cross-platform-app
-// convention is that the user runs the package-manager command, not the
-// app itself. See research-update-patterns notes (Tailscale, OrbStack)
-// for context.
-func BrewUpgradeCommand() string {
- return "brew upgrade --cask wireguide"
-}
-
// Release represents a GitHub release.
type Release struct {
TagName string `json:"tag_name"`
diff --git a/internal/update/checker_test.go b/internal/update/checker_test.go
index a1b6773..f73df96 100644
--- a/internal/update/checker_test.go
+++ b/internal/update/checker_test.go
@@ -636,8 +636,12 @@ func TestDownloadUpdate_HTTPError(t *testing.T) {
func TestMatchAsset_FindsPlatformAsset(t *testing.T) {
name := fmt.Sprintf("WireGuide-%s-%s.dmg", runtime.GOOS, runtime.GOARCH)
+ // The decoy must never match the running platform. A linux-amd64
+ // decoy broke this test the first time it ran on Linux CI: both
+ // assets matched and the decoy's .tar.gz is the preferred extension
+ // there, so matchAsset (correctly) returned the decoy.
assets := []Asset{
- {Name: "WireGuide-linux-amd64.tar.gz"},
+ {Name: "WireGuide-plan9-mips.tar.gz"},
{Name: name},
}
got := matchAsset(assets)
diff --git a/internal/wifi/detect_linux.go b/internal/wifi/detect_linux.go
index 8c34112..baa9e30 100644
--- a/internal/wifi/detect_linux.go
+++ b/internal/wifi/detect_linux.go
@@ -29,12 +29,12 @@ import (
// We must NOT call conn.Close() — that would terminate the connection
// shared with internal/reconnect/sleep_linux.go. We only unsubscribe our
// own matcher + signal channel on stop.
-func startLinuxDBusWatcher(onChange func()) (stop func()) {
+func startLinuxDBusWatcher(onChange func()) (stop func(), attached bool) {
noop := func() {}
conn, err := dbus.SystemBus()
if err != nil {
slog.Debug("wifi: dbus unavailable, no event-driven SSID watcher", "error", err)
- return noop
+ return noop, false
}
matchOpts := []dbus.MatchOption{
@@ -43,7 +43,7 @@ func startLinuxDBusWatcher(onChange func()) (stop func()) {
}
if err := conn.AddMatchSignal(matchOpts...); err != nil {
slog.Debug("wifi: NM AddMatchSignal failed", "error", err)
- return noop
+ return noop, false
}
ch := make(chan *dbus.Signal, 16)
@@ -89,5 +89,5 @@ func startLinuxDBusWatcher(onChange func()) (stop func()) {
slog.Info("wifi: NetworkManager DBus watcher started (Wireless.StateChanged only)")
return func() {
once.Do(func() { close(stopCh) })
- }
+ }, true
}
diff --git a/internal/wifi/detect_linux_stub.go b/internal/wifi/detect_linux_stub.go
index 5a38bb8..f0d3b77 100644
--- a/internal/wifi/detect_linux_stub.go
+++ b/internal/wifi/detect_linux_stub.go
@@ -7,6 +7,6 @@ package wifi
// changes instantly; on other OSes the wifi.Monitor's per-platform
// detection (CoreWLAN events on macOS, Wlanapi notifications on Windows)
// covers the same ground.
-func startLinuxDBusWatcher(onChange func()) (stop func()) {
- return func() {}
+func startLinuxDBusWatcher(onChange func()) (stop func(), attached bool) {
+ return func() {}, false
}
diff --git a/internal/wifi/detect_windows.go b/internal/wifi/detect_windows.go
index c22e728..7f225bc 100644
--- a/internal/wifi/detect_windows.go
+++ b/internal/wifi/detect_windows.go
@@ -15,12 +15,12 @@ import (
// notifications and invokes onChange on every transition. Returns a stop
// function. Falls back to no-op (returns nil stop) when wlanapi.dll is
// unavailable (server SKUs without WLAN service, headless containers).
-func startWindowsWlanWatcher(onChange func()) (stop func()) {
+func startWindowsWlanWatcher(onChange func()) (stop func(), attached bool) {
noop := func() {}
if err := wlanLazyOpenHandle(); err != nil {
slog.Debug("wifi: wlanapi.dll OpenHandle failed", "error", err)
- return noop
+ return noop, false
}
cb := syscall.NewCallback(func(notif uintptr, _ uintptr) uintptr {
@@ -43,7 +43,7 @@ func startWindowsWlanWatcher(onChange func()) (stop func()) {
)
if ret != 0 {
slog.Debug("wifi: WlanRegisterNotification failed", "status", ret)
- return noop
+ return noop, false
}
slog.Info("wifi: Wlanapi notification subscribed")
@@ -62,7 +62,7 @@ func startWindowsWlanWatcher(onChange func()) (stop func()) {
uintptr(unsafe.Pointer(&prev)),
)
})
- }
+ }, true
}
var (
diff --git a/internal/wifi/detect_windows_stub.go b/internal/wifi/detect_windows_stub.go
index 1ec5d94..1c589df 100644
--- a/internal/wifi/detect_windows_stub.go
+++ b/internal/wifi/detect_windows_stub.go
@@ -5,6 +5,6 @@ package wifi
// startWindowsWlanWatcher is a no-op on non-Windows platforms. The Windows
// build uses wlanapi's WlanRegisterNotification to react instantly to SSID
// changes; the per-OS poll handles the same role elsewhere.
-func startWindowsWlanWatcher(onChange func()) (stop func()) {
- return func() {}
+func startWindowsWlanWatcher(onChange func()) (stop func(), attached bool) {
+ return func() {}, false
}
diff --git a/internal/wifi/gateway_darwin.go b/internal/wifi/gateway_darwin.go
index 1307466..2666d26 100644
--- a/internal/wifi/gateway_darwin.go
+++ b/internal/wifi/gateway_darwin.go
@@ -71,15 +71,3 @@ func parseARPMAC(out string) string {
return normalizeMAC(m)
}
-// normalizeMAC lower-cases and zero-pads each octet so BSD's "0:1e:..."
-// and Linux's "00:1e:..." compare equal.
-func normalizeMAC(mac string) string {
- parts := strings.Split(mac, ":")
- for i, p := range parts {
- if len(p) == 1 {
- parts[i] = "0" + p
- }
- parts[i] = strings.ToLower(parts[i])
- }
- return strings.Join(parts, ":")
-}
diff --git a/internal/wifi/gateway_linux.go b/internal/wifi/gateway_linux.go
index 5afa838..57e25b9 100644
--- a/internal/wifi/gateway_linux.go
+++ b/internal/wifi/gateway_linux.go
@@ -3,96 +3,49 @@
package wifi
import (
- "bufio"
- "encoding/binary"
- "net"
"os"
- "strconv"
"strings"
)
// GatewayMAC returns the lower-cased MAC of the IPv4 default gateway,
// read straight from /proc (no exec, locale-independent). "" when
-// unavailable.
+// unavailable. Route selection honours flags/metric/netmask and skips
+// tunnel and virtual interfaces; the ARP lookup is scoped to the route's
+// device and requires a completed entry (issue #22).
func GatewayMAC() string {
- gw := defaultGatewayIPLinux()
+ routeTable, err := os.ReadFile("/proc/net/route")
+ if err != nil {
+ return ""
+ }
+ gw, iface := bestDefaultRoute(routeTable, func(name string) bool {
+ return isTunnelIface(name) || isVirtualIface(name)
+ })
if gw == "" {
return ""
}
- return arpMACForIP(gw)
-}
-
-// defaultGatewayIPLinux parses /proc/net/route for the default route
-// (destination 00000000) and returns its gateway as a dotted IPv4.
-func defaultGatewayIPLinux() string {
- f, err := os.Open("/proc/net/route")
+ arpTable, err := os.ReadFile("/proc/net/arp")
if err != nil {
return ""
}
- defer f.Close()
- sc := bufio.NewScanner(f)
- sc.Scan() // header
- for sc.Scan() {
- fields := strings.Fields(sc.Text())
- if len(fields) < 3 {
- continue
- }
- // fields: Iface Destination Gateway ...
- if fields[1] != "00000000" {
- continue
- }
- gwHex := fields[2]
- v, err := strconv.ParseUint(gwHex, 16, 32)
- if err != nil {
- continue
- }
- // The value is little-endian in /proc.
- ip := make(net.IP, 4)
- binary.LittleEndian.PutUint32(ip, uint32(v))
- if ip.IsUnspecified() {
- continue
- }
- return ip.String()
- }
- return ""
+ return arpMACForIPOnIface(arpTable, gw, iface)
}
-// arpMACForIP looks up ip in /proc/net/arp and returns its normalised MAC.
-func arpMACForIP(ip string) string {
- f, err := os.Open("/proc/net/arp")
- if err != nil {
- return ""
+// isVirtualIface reports whether the named interface has no backing
+// hardware device. /sys/class/net//device is a symlink to the
+// PCI/USB/SDIO device and is absent for every bridge, veth, tun/tap,
+// bond and WireGuard interface — a single check that catches docker0,
+// virbr0, tailscale0, vmnet*, CNI bridges and the rest of the
+// name-denylist's blind spots. The tun_flags probe additionally catches
+// tun/tap devices, mirroring internal/reconnect's isTunnel.
+func isVirtualIface(name string) bool {
+ if name == "" || strings.ContainsAny(name, "/\\") {
+ return true
}
- defer f.Close()
- sc := bufio.NewScanner(f)
- sc.Scan() // header
- for sc.Scan() {
- fields := strings.Fields(sc.Text())
- // fields: IPaddress HWtype Flags HWaddress Mask Device
- if len(fields) < 4 {
- continue
- }
- if fields[0] != ip {
- continue
- }
- mac := fields[3]
- if mac == "00:00:00:00:00:00" {
- return ""
- }
- return normalizeMAC(mac)
+ if _, err := os.Stat("/sys/class/net/" + name + "/tun_flags"); err == nil {
+ return true
}
- return ""
-}
-
-// normalizeMAC lower-cases and zero-pads each octet so platforms that
-// drop leading zeros compare equal.
-func normalizeMAC(mac string) string {
- parts := strings.Split(mac, ":")
- for i, p := range parts {
- if len(p) == 1 {
- parts[i] = "0" + p
- }
- parts[i] = strings.ToLower(parts[i])
+ if _, err := os.Stat("/sys/class/net/" + name + "/device"); err == nil {
+ return false
}
- return strings.Join(parts, ":")
+ return true
}
diff --git a/internal/wifi/gateway_parse.go b/internal/wifi/gateway_parse.go
new file mode 100644
index 0000000..5a72b39
--- /dev/null
+++ b/internal/wifi/gateway_parse.go
@@ -0,0 +1,111 @@
+package wifi
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/binary"
+ "net"
+ "strconv"
+ "strings"
+)
+
+// Pure parsers for Linux /proc/net/route and /proc/net/arp, kept in an
+// untagged file so their unit tests run on every development platform.
+// The linux-only wrappers in gateway_linux.go feed them the real files.
+//
+// These replaced a first-match parser that ignored route flags, metric,
+// the netmask column, and tunnel interfaces, and an ARP lookup keyed by
+// IP alone (issue #22): a wg0 default route, a higher-metric secondary
+// uplink, or a stale/incomplete ARP entry on the wrong device could all
+// fingerprint the wrong network — and gateway-MAC is a *trust* signal
+// for Automation rules.
+
+// rtfUp is Linux's RTF_UP route flag (route is usable). The value is
+// ABI-stable; defined locally so this file stays platform-untagged.
+const rtfUp = 0x1
+
+// atfCom is Linux's ATF_COM ARP flag (lookup complete). Entries without
+// it are in-progress or failed resolutions whose HW address is garbage.
+const atfCom = 0x2
+
+// bestDefaultRoute picks the usable IPv4 default route with the lowest
+// metric from /proc/net/route content and returns its gateway (dotted
+// form) and interface. isTunnel filters interfaces that must never count
+// (VPN adapters — including our own — and virtual devices).
+func bestDefaultRoute(routeTable []byte, isTunnel func(string) bool) (gw, iface string) {
+ sc := bufio.NewScanner(bytes.NewReader(routeTable))
+ sc.Scan() // header
+ bestMetric := uint64(0)
+ found := false
+ for sc.Scan() {
+ f := strings.Fields(sc.Text())
+ // Iface Destination Gateway Flags RefCnt Use Metric Mask ...
+ if len(f) < 8 || f[1] != "00000000" || f[7] != "00000000" {
+ continue
+ }
+ flags, err1 := strconv.ParseUint(f[3], 16, 64)
+ metric, err2 := strconv.ParseUint(f[6], 10, 64)
+ if err1 != nil || err2 != nil || flags&rtfUp == 0 {
+ continue
+ }
+ if isTunnel != nil && isTunnel(f[0]) {
+ continue
+ }
+ v, err := strconv.ParseUint(f[2], 16, 32)
+ if err != nil {
+ continue
+ }
+ ip := make(net.IP, 4)
+ binary.LittleEndian.PutUint32(ip, uint32(v)) // little-endian in /proc
+ if ip.IsUnspecified() {
+ continue
+ }
+ if !found || metric < bestMetric {
+ found = true
+ bestMetric = metric
+ gw = ip.String()
+ iface = f[0]
+ }
+ }
+ return gw, iface
+}
+
+// arpMACForIPOnIface finds ip's completed ARP entry on the given device
+// in /proc/net/arp content and returns its normalised MAC. Filtering by
+// device matters on hosts where two networks share a gateway IP (a
+// docker bridge and the LAN both using 192.168.x.1); requiring ATF_COM
+// rejects incomplete/stale entries whose HW address is meaningless.
+func arpMACForIPOnIface(arpTable []byte, ip, iface string) string {
+ sc := bufio.NewScanner(bytes.NewReader(arpTable))
+ sc.Scan() // header
+ for sc.Scan() {
+ f := strings.Fields(sc.Text())
+ // IPaddress HWtype Flags HWaddress Mask Device
+ if len(f) < 6 || f[0] != ip || f[5] != iface {
+ continue
+ }
+ flags, err := strconv.ParseUint(strings.TrimPrefix(f[2], "0x"), 16, 32)
+ if err != nil || flags&atfCom == 0 {
+ continue
+ }
+ mac := f[3]
+ if mac == "00:00:00:00:00:00" {
+ continue
+ }
+ return normalizeMAC(mac)
+ }
+ return ""
+}
+
+// normalizeMAC lower-cases and zero-pads each octet so BSD's "0:1e:..."
+// and Linux's "00:1e:..." compare equal.
+func normalizeMAC(mac string) string {
+ parts := strings.Split(mac, ":")
+ for i, p := range parts {
+ if len(p) == 1 {
+ parts[i] = "0" + p
+ }
+ parts[i] = strings.ToLower(parts[i])
+ }
+ return strings.Join(parts, ":")
+}
diff --git a/internal/wifi/gateway_parse_test.go b/internal/wifi/gateway_parse_test.go
new file mode 100644
index 0000000..38a4593
--- /dev/null
+++ b/internal/wifi/gateway_parse_test.go
@@ -0,0 +1,66 @@
+package wifi
+
+import "testing"
+
+// /proc/net/route fixture columns:
+// Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
+const routeFixture = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n" +
+ // wg0 default route, metric 0 — must be excluded (tunnel)
+ "wg0\t00000000\t00000000\t0001\t0\t0\t0\t00000000\t0\t0\t0\n" +
+ // eth1 default route, DOWN (flags lack RTF_UP) — must be excluded
+ "eth1\t00000000\t0100A8C0\t0002\t0\t0\t50\t00000000\t0\t0\t0\n" +
+ // eth0 default route, metric 100, gateway 192.168.1.1 (little-endian hex)
+ "eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n" +
+ // wlan0 default route, metric 600, gateway 192.168.1.1
+ "wlan0\t00000000\t0101A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n" +
+ // eth0 non-default route (mask not /0) — must be ignored
+ "eth0\t0001A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\n"
+
+func TestBestDefaultRoutePicksLowestMetricRealIface(t *testing.T) {
+ isTunnel := func(name string) bool { return name == "wg0" }
+ gw, iface := bestDefaultRoute([]byte(routeFixture), isTunnel)
+ if gw != "192.168.1.1" {
+ t.Errorf("gw: got %q, want 192.168.1.1", gw)
+ }
+ if iface != "eth0" {
+ t.Errorf("iface: got %q, want eth0 (metric 100 beats wlan0's 600)", iface)
+ }
+}
+
+func TestBestDefaultRouteAllFiltered(t *testing.T) {
+ gw, iface := bestDefaultRoute([]byte(routeFixture), func(string) bool { return true })
+ if gw != "" || iface != "" {
+ t.Errorf("got (%q,%q), want empty when every iface is a tunnel", gw, iface)
+ }
+}
+
+// /proc/net/arp fixture columns:
+// IPaddress HWtype Flags HWaddress Mask Device
+const arpFixture = "IP address HW type Flags HW address Mask Device\n" +
+ // same gateway IP known via docker0 — wrong device, must be skipped
+ "192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:99 * docker0\n" +
+ // incomplete entry on the right device — must be skipped (no ATF_COM)
+ "192.168.1.1 0x1 0x0 00:00:00:00:00:00 * eth0\n" +
+ // completed entry on the right device
+ "192.168.1.1 0x1 0x2 B0:38:6C:54:8B:AB * eth0\n"
+
+func TestArpMACForIPOnIface(t *testing.T) {
+ got := arpMACForIPOnIface([]byte(arpFixture), "192.168.1.1", "eth0")
+ if got != "b0:38:6c:54:8b:ab" {
+ t.Errorf("got %q, want b0:38:6c:54:8b:ab (normalised, right device, completed)", got)
+ }
+}
+
+func TestArpMACForIPOnIfaceWrongDevice(t *testing.T) {
+ if got := arpMACForIPOnIface([]byte(arpFixture), "192.168.1.1", "wlan0"); got != "" {
+ t.Errorf("got %q, want empty for a device with no entry", got)
+ }
+}
+
+func TestArpMACIncompleteOnly(t *testing.T) {
+ incomplete := "IP address HW type Flags HW address Mask Device\n" +
+ "10.0.0.1 0x1 0x0 aa:bb:cc:dd:ee:ff * eth0\n"
+ if got := arpMACForIPOnIface([]byte(incomplete), "10.0.0.1", "eth0"); got != "" {
+ t.Errorf("got %q, want empty for an incomplete (non-ATF_COM) entry", got)
+ }
+}
diff --git a/internal/wifi/monitor.go b/internal/wifi/monitor.go
index 15883fe..fa8e90a 100644
--- a/internal/wifi/monitor.go
+++ b/internal/wifi/monitor.go
@@ -64,20 +64,17 @@ func (m *Monitor) Start() {
}
m.running = true
m.stopCh = make(chan struct{})
- m.wg.Add(1)
m.mu.Unlock()
- go func() {
- defer m.wg.Done()
- m.poll()
- }()
- // Linux-only: wake the poller immediately on NetworkManager
+ // Event watchers start BEFORE the poll goroutine so the poll cadence
+ // can depend on whether one attached.
+ // Linux: wake the poller immediately on NetworkManager
// DeviceStateChanged so users see SSID transitions react in <1s instead
- // of waiting for the 5s tick. No-op on non-Linux.
- stopDBus := startLinuxDBusWatcher(func() { m.checkNow() })
- // Windows-only: Wlanapi notifications for instant SSID react. No-op on
+ // of waiting for the poll tick. No-op on non-Linux.
+ stopDBus, dbusAttached := startLinuxDBusWatcher(func() { m.checkNow() })
+ // Windows: Wlanapi notifications for instant SSID react. No-op on
// other platforms.
- stopWlan := startWindowsWlanWatcher(func() { m.checkNow() })
+ stopWlan, wlanAttached := startWindowsWlanWatcher(func() { m.checkNow() })
m.stopDBusWatcher = func() {
if stopDBus != nil {
stopDBus()
@@ -87,7 +84,27 @@ func (m *Monitor) Start() {
}
}
- slog.Info("WiFi monitor started (polling)")
+ // With an event-driven watcher attached the poll is only a safety net
+ // for missed signals, so it can run slowly. Without one (no
+ // NetworkManager, no wlanapi) it is the sole SSID source and keeps the
+ // 5 s cadence. On Linux the fast path matters doubly: CurrentSSID()
+ // shells out to nmcli, so a 5 s poll alongside a working DBus watcher
+ // burned ~17k subprocess spawns a day for nothing.
+ interval := 5 * time.Second
+ eventDriven := dbusAttached || wlanAttached
+ if eventDriven {
+ interval = 60 * time.Second
+ }
+
+ m.mu.Lock()
+ m.wg.Add(1)
+ m.mu.Unlock()
+ go func() {
+ defer m.wg.Done()
+ m.poll(interval)
+ }()
+
+ slog.Info("WiFi monitor started", "event_driven", eventDriven, "poll_interval", interval)
}
// checkNow forces an immediate SSID re-read outside the 5s tick. Used by the
@@ -147,7 +164,7 @@ func (m *Monitor) ReportExternalSSID(ssid string) {
}
}
-func (m *Monitor) poll() {
+func (m *Monitor) poll(interval time.Duration) {
// On macOS the root helper cannot read the SSID — Location Services is
// scoped to the GUI .app bundle, so CurrentSSID() returns "". Polling it
// would overwrite the authoritative GUI-reported SSID (via
@@ -161,7 +178,7 @@ func (m *Monitor) poll() {
m.mu.Lock()
m.lastSSID = CurrentSSID()
m.mu.Unlock()
- ticker := time.NewTicker(5 * time.Second)
+ ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
diff --git a/internal/wifi/physical.go b/internal/wifi/physical.go
index 4a32b99..1f5cf37 100644
--- a/internal/wifi/physical.go
+++ b/internal/wifi/physical.go
@@ -26,7 +26,12 @@ func PhysicalInterfaceIPs() []net.IP {
if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 {
continue
}
- if isTunnelIface(ifi.Name) {
+ // isVirtualIface (Linux sysfs) catches what the name denylist
+ // can't: docker0, virbr0, tailscale0, veth*, CNI bridges — all
+ // up, non-loopback, carrying routable IPs that would otherwise
+ // satisfy subnet Automation rules for networks the machine isn't
+ // physically on (issue #22).
+ if isTunnelIface(ifi.Name) || isVirtualIface(ifi.Name) {
continue
}
addrs, err := ifi.Addrs()
@@ -65,7 +70,8 @@ func PhysicalSubnets() []string {
seen := map[string]bool{}
var out []string
for _, ifi := range ifaces {
- if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 || isTunnelIface(ifi.Name) {
+ if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 ||
+ isTunnelIface(ifi.Name) || isVirtualIface(ifi.Name) {
continue
}
addrs, err := ifi.Addrs()
diff --git a/internal/wifi/physical_other.go b/internal/wifi/physical_other.go
new file mode 100644
index 0000000..49f1b1a
--- /dev/null
+++ b/internal/wifi/physical_other.go
@@ -0,0 +1,10 @@
+//go:build !linux
+
+package wifi
+
+// isVirtualIface is Linux-only sysfs knowledge; macOS and Windows rely on
+// the name-based isTunnelIface filter alone. On macOS the tunnel/virtual
+// namespace is well-conventioned (utun*, bridge*, awdl* — the latter two
+// are excluded by the link-local check on their addresses), and Windows
+// virtual adapters are caught by the explicit wireguard/wintun match.
+func isVirtualIface(string) bool { return false }
diff --git a/internal/wifi/rules.go b/internal/wifi/rules.go
index 46cf284..29b8618 100644
--- a/internal/wifi/rules.go
+++ b/internal/wifi/rules.go
@@ -21,14 +21,6 @@ type TunnelSSIDs struct {
AutoConnectSSIDs []string `json:"auto_connect_ssids"`
}
-// DefaultRules returns empty rules with maps initialized so
-// JSON marshaling produces {} rather than null for empty per-tunnel.
-func DefaultRules() *Rules {
- return &Rules{
- PerTunnel: make(map[string]TunnelSSIDs),
- }
-}
-
// Action determines what to do when the system joins the given SSID.
// Returns:
//
diff --git a/main.go b/main.go
index 7b990f3..9bac3b1 100644
--- a/main.go
+++ b/main.go
@@ -47,6 +47,7 @@ func main() {
helperMode := flag.Bool("helper", false, "run as privileged helper")
socketPath := flag.String("socket", "", "socket path for IPC")
socketUID := flag.Int("uid", -1, "socket owner UID (Unix only)")
+ ownerSID := flag.String("owner-sid", "", "socket owner SID (Windows only)")
dataDir := flag.String("data-dir", "", "data directory for crash recovery")
flag.Parse()
@@ -87,7 +88,7 @@ func main() {
}
}
log.Println("WireGuide helper starting...")
- if err := helper.Run(*socketPath, *socketUID, *dataDir); err != nil {
+ if err := helper.Run(*socketPath, *socketUID, *ownerSID, *dataDir); err != nil {
log.Fatal("helper error:", err)
}
return