diff --git a/.github/workflows/new-release.yml b/.github/workflows/new-release.yml index b3bded5..b964c04 100644 --- a/.github/workflows/new-release.yml +++ b/.github/workflows/new-release.yml @@ -21,14 +21,28 @@ jobs: with: go-version: '1.25.6' + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + # The tray binary embeds internal/gui/frontend/dist (//go:embed), which is + # gitignored, so the frontend must be compiled before `go build ./cmd/tray`. + - name: Build tray frontend + working-directory: internal/gui/frontend + run: | + bun install + bun run build + - name: Build run: | GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/neozmmv/blindspot/cmd.Version=${{ github.ref_name }}" -o blindspot-linux-amd64 . GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/neozmmv/blindspot/cmd.Version=${{ github.ref_name }}" -o blindspot-linux-arm64 . GOOS=windows GOARCH=amd64 go build -ldflags "-X github.com/neozmmv/blindspot/cmd.Version=${{ github.ref_name }}" -o blindspot.exe . + GOOS=windows GOARCH=amd64 go build -ldflags "-H windowsgui -X github.com/neozmmv/blindspot/cmd.Version=${{ github.ref_name }}" -o blindspot-tray.exe ./cmd/tray GOOS=darwin GOARCH=amd64 go build -ldflags "-X github.com/neozmmv/blindspot/cmd.Version=${{ github.ref_name }}" -o blindspot-darwin-amd64 . GOOS=darwin GOARCH=arm64 go build -ldflags "-X github.com/neozmmv/blindspot/cmd.Version=${{ github.ref_name }}" -o blindspot-darwin-arm64 . - + - name: Upload Artifacts uses: actions/upload-artifact@v4 with: @@ -37,6 +51,7 @@ jobs: blindspot-linux-amd64 blindspot-linux-arm64 blindspot.exe + blindspot-tray.exe blindspot-darwin-amd64 blindspot-darwin-arm64 @@ -58,5 +73,6 @@ jobs: blindspot-linux-amd64 blindspot-linux-arm64 blindspot.exe + blindspot-tray.exe blindspot-darwin-amd64 blindspot-darwin-arm64 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7d7c7e3..7d679de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist *.exe -wintun.dll \ No newline at end of file +wintun.dll +build \ No newline at end of file diff --git a/Makefile b/Makefile index 5edcf52..258f9e2 100644 --- a/Makefile +++ b/Makefile @@ -2,10 +2,22 @@ BINARY=blindspot VERSION=$(shell git describe --tags --abbrev=0) # VERSION HERE USE LATEST TAG -.PHONY: build build-win build-linux +.PHONY: build build-win build-linux frontend -build-win: +FRONTEND_DIR=internal/gui/frontend + +# The tray binary embeds internal/gui/frontend/dist (//go:embed), which is gitignored, +# so the frontend must be compiled before building the tray. +frontend: + cd $(FRONTEND_DIR) && bun install && bun run build + +# Two Windows binaries: the console-subsystem CLI (blindspot.exe) behaves like a +# normal command-line tool from a terminal, and the GUI-subsystem tray +# (blindspot-tray.exe, -H windowsgui) launches without ever opening a console. The +# tray shells out to the CLI sitting next to it, so ship them in the same folder. +build-win: frontend GOOS=windows GOARCH=amd64 go build -ldflags="-X github.com/neozmmv/blindspot/cmd.Version=$(VERSION)" -o dist/$(BINARY).exe . + GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui -X github.com/neozmmv/blindspot/cmd.Version=$(VERSION)" -o dist/$(BINARY)-tray.exe ./cmd/tray build-linux: GOOS=linux GOARCH=amd64 go build -ldflags="-X github.com/neozmmv/blindspot/cmd.Version=$(VERSION)" -o dist/$(BINARY)-linux-amd64 . diff --git a/cmd/tray/main.go b/cmd/tray/main.go new file mode 100644 index 0000000..d65d20f --- /dev/null +++ b/cmd/tray/main.go @@ -0,0 +1,11 @@ +// Command blindspot-tray is the system-tray GUI for Blindspot. It is built as a +// GUI-subsystem binary (-H windowsgui) so launching it never opens a console +// window. It drives the P2P VPN by shelling out to the sibling blindspot CLI, so +// the two binaries ship side by side. +package main + +import "github.com/neozmmv/blindspot/internal/gui" + +func main() { + gui.Run() +} diff --git a/cmd/update_windows.go b/cmd/update_windows.go index 8797546..cd6b9b7 100644 --- a/cmd/update_windows.go +++ b/cmd/update_windows.go @@ -4,30 +4,47 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "github.com/spf13/cobra" ) -// batch workaround to replace the current executable on next run +// pwshScript downloads the latest CLI and tray binaries, then hands off to a small +// batch file that (after this process exits) replaces both in place. The tray is +// stopped before its exe is swapped and restarted afterwards if it had been running. +// Destinations are passed in via BS_CLI_DEST / BS_TRAY_DEST so the update lands +// wherever blindspot is actually installed, keeping the two binaries side by side. var pwshScript = ` $ErrorActionPreference = "Stop" -$Url = "https://github.com/neozmmv/blindspot/releases/latest/download/blindspot.exe" -$Dest = "$env:LOCALAPPDATA\Microsoft\WindowsApps\blindspot.exe" -$Tmp = "$env:TEMP\blindspot_update.exe" +$Base = "https://github.com/neozmmv/blindspot/releases/latest/download" +$CliDest = $env:BS_CLI_DEST +$TrayDest = $env:BS_TRAY_DEST +$TmpCli = "$env:TEMP\blindspot_update.exe" +$TmpTray = "$env:TEMP\blindspot_tray_update.exe" -Write-Host "Downloading latest blindspot..." -Invoke-WebRequest -Uri $Url -OutFile $Tmp +Write-Host "Downloading latest Blindspot (CLI + tray)..." +Invoke-WebRequest -Uri "$Base/blindspot.exe" -OutFile $TmpCli +Invoke-WebRequest -Uri "$Base/blindspot-tray.exe" -OutFile $TmpTray + +# Restart the tray afterwards only if it is currently running. +$restartLine = "" +if (Get-Process blindspot-tray -ErrorAction SilentlyContinue) { + $restartLine = "start """" ""$TrayDest""" +} $batch = "$env:TEMP\blindspot_update.bat" @" @echo off timeout /t 1 /nobreak >nul -move /y "$Tmp" "$Dest" +taskkill /f /im blindspot-tray.exe >nul 2>&1 +move /y "$TmpCli" "$CliDest" +move /y "$TmpTray" "$TrayDest" +$restartLine del "%~f0" "@ | Out-File -FilePath $batch -Encoding ascii Start-Process -FilePath $batch -WindowStyle Hidden -Write-Host "Update downloaded. Applying on next run." +Write-Host "Update downloaded. Applying now - the tray restarts if it was running." ` func init() { @@ -36,14 +53,24 @@ func init() { var updateWindowsCmd = &cobra.Command{ Use: "update", - Short: "Update blindspot", + Short: "Update blindspot (CLI and tray)", Run: func(cmd *cobra.Command, args []string) { + exe, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "Could not locate the blindspot executable: %v\n", err) + os.Exit(1) + } + dir := filepath.Dir(exe) + fmt.Println("Starting blindspot update...") - scriptCmd := exec.Command("powershell", "-Command", pwshScript) + scriptCmd := exec.Command("powershell", "-NoProfile", "-Command", pwshScript) + scriptCmd.Env = append(os.Environ(), + "BS_CLI_DEST="+filepath.Join(dir, "blindspot.exe"), + "BS_TRAY_DEST="+filepath.Join(dir, "blindspot-tray.exe"), + ) scriptCmd.Stdout = os.Stdout scriptCmd.Stderr = os.Stderr - err := scriptCmd.Run() - if err != nil { + if err := scriptCmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error occurred while updating blindspot: %v\n", err) os.Exit(1) } diff --git a/go.mod b/go.mod index 1461de7..800ee13 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,30 @@ module github.com/neozmmv/blindspot go 1.25.6 require ( - github.com/flynn/noise v1.1.0 // indirect + github.com/flynn/noise v1.1.0 + github.com/pion/stun v0.6.1 + github.com/spf13/cobra v1.10.2 + github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 + golang.org/x/crypto v0.52.0 + golang.org/x/sys v0.45.0 + golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 +) + +require ( + github.com/adrg/xdg v0.5.3 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/pion/dtls/v2 v2.2.7 // indirect github.com/pion/logging v0.2.2 // indirect - github.com/pion/stun v0.6.1 // indirect github.com/pion/transport/v2 v2.2.1 // indirect - github.com/spf13/cobra v1.10.2 // indirect - github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/crypto v0.52.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect golang.org/x/net v0.54.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/time v0.8.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect ) diff --git a/go.sum b/go.sum index 13cd4f8..5511808 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,40 @@ +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= +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= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= @@ -16,12 +43,14 @@ github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -29,6 +58,10 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -50,11 +83,14 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -67,6 +103,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -77,6 +115,10 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+D 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= diff --git a/internal/gui/.gitignore b/internal/gui/.gitignore new file mode 100644 index 0000000..5d7826e --- /dev/null +++ b/internal/gui/.gitignore @@ -0,0 +1,11 @@ +.task +bin +frontend/dist +frontend/node_modules +build/linux/appimage/build +build/windows/nsis/MicrosoftEdgeWebview2Setup.exe +# Mobile generated (per-machine; overlays hold absolute paths) +build/ios/xcode/overlay.json +build/ios/xcode/gen/ +build/android/overlay.json +build/android/gen/ \ No newline at end of file diff --git a/internal/gui/README.md b/internal/gui/README.md new file mode 100644 index 0000000..ad12c3f --- /dev/null +++ b/internal/gui/README.md @@ -0,0 +1,59 @@ +# Welcome to Your New Wails3 Project! + +Congratulations on generating your Wails3 application! This README will guide you through the next steps to get your project up and running. + +## Getting Started + +1. Navigate to your project directory in the terminal. + +2. To run your application in development mode, use the following command: + + ``` + wails3 dev + ``` + + This will start your application and enable hot-reloading for both frontend and backend changes. + +3. To build your application for production, use: + + ``` + wails3 build + ``` + + This will create a production-ready executable in the `build` directory. + +## Exploring Wails3 Features + +Now that you have your project set up, it's time to explore the features that Wails3 offers: + +1. **Check out the examples**: The best way to learn is by example. Visit the `examples` directory in the `v3/examples` directory to see various sample applications. + +2. **Run an example**: To run any of the examples, navigate to the example's directory and use: + + ``` + go run . + ``` + + Note: Some examples may be under development during the alpha phase. + +3. **Explore the documentation**: Visit the [Wails3 documentation](https://v3.wails.io/) for in-depth guides and API references. + +4. **Join the community**: Have questions or want to share your progress? Join the [Wails Discord](https://discord.gg/JDdSxwjhGf) or visit the [Wails discussions on GitHub](https://github.com/wailsapp/wails/discussions). + +## Project Structure + +Take a moment to familiarize yourself with your project structure: + +- `frontend/`: Contains your frontend code (HTML, CSS, JavaScript/TypeScript) +- `main.go`: The entry point of your Go backend +- `app.go`: Define your application structure and methods here +- `wails.json`: Configuration file for your Wails project + +## Next Steps + +1. Modify the frontend in the `frontend/` directory to create your desired UI. +2. Add backend functionality in `main.go`. +3. Use `wails3 dev` to see your changes in real-time. +4. When ready, build your application with `wails3 build`. + +Happy coding with Wails3! If you encounter any issues or have questions, don't hesitate to consult the documentation or reach out to the Wails community. diff --git a/internal/gui/Taskfile.yml b/internal/gui/Taskfile.yml new file mode 100644 index 0000000..f547a5d --- /dev/null +++ b/internal/gui/Taskfile.yml @@ -0,0 +1,65 @@ +version: '3' + +vars: + APP_NAME: "gui" + BIN_DIR: "bin" + PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "bun"}}' + VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' + # Target OS for build/package/run. Defaults to the host OS, and is overridden + # by `wails3 build GOOS=...` (or the GOOS env var) for cross-compilation. The + # tasks below dispatch to the matching platform Taskfile via this variable. + GOOS: '{{.GOOS | default OS}}' + +includes: + common: ./build/Taskfile.yml + windows: ./build/windows/Taskfile.yml + darwin: ./build/darwin/Taskfile.yml + linux: ./build/linux/Taskfile.yml + ios: ./build/ios/Taskfile.yml + android: ./build/android/Taskfile.yml + +tasks: + build: + summary: Builds the application + cmds: + - task: "{{.GOOS}}:build" + + package: + summary: Packages a production build of the application + cmds: + - task: "{{.GOOS}}:package" + + run: + summary: Runs the application + cmds: + - task: "{{.GOOS}}:run" + + dev: + summary: Runs the application in development mode + cmds: + - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}} + + setup:docker: + summary: Builds Docker image for cross-compilation (~800MB download) + cmds: + - task: common:setup:docker + + build:server: + summary: Builds the application in server mode (no GUI, HTTP server only) + cmds: + - task: common:build:server + + run:server: + summary: Runs the application in server mode + cmds: + - task: common:run:server + + build:docker: + summary: Builds a Docker image for server mode deployment + cmds: + - task: common:build:docker + + run:docker: + summary: Builds and runs the Docker image + cmds: + - task: common:run:docker diff --git a/internal/gui/frontend/.npmrc b/internal/gui/frontend/.npmrc new file mode 100644 index 0000000..5e68cfa --- /dev/null +++ b/internal/gui/frontend/.npmrc @@ -0,0 +1,5 @@ +# Refuse to install package versions published less than 7 days (10080 minutes) +# ago, reducing exposure to freshly compromised or malicious releases. +# Honoured by pnpm and bun; npm currently has no native support and ignores it, +# so this is harmless today and forward-compatible. +minimum-release-age=10080 diff --git a/internal/gui/frontend/Inter Font License.txt b/internal/gui/frontend/Inter Font License.txt new file mode 100644 index 0000000..b525cbf --- /dev/null +++ b/internal/gui/frontend/Inter Font License.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/index.js b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/index.js new file mode 100644 index 0000000..e934bff --- /dev/null +++ b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/index.js @@ -0,0 +1,13 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as TrayService from "./trayservice.js"; +export { + TrayService +}; + +export { + Peer, + Status +} from "./models.js"; diff --git a/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/models.js b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/models.js new file mode 100644 index 0000000..2569f50 --- /dev/null +++ b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/models.js @@ -0,0 +1,131 @@ +// @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"; + +/** + * Peer is one connected member of the active session. + */ +export class Peer { + /** + * Creates a new Peer instance. + * @param {Partial} [$$source = {}] - The source object to create the Peer. + */ + constructor($$source = {}) { + if (!("virtualIP" in $$source)) { + /** + * @member + * @type {string} + */ + this["virtualIP"] = ""; + } + if (!("publicAddr" in $$source)) { + /** + * @member + * @type {string} + */ + this["publicAddr"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Peer instance from a string or object. + * @param {any} [$$source = {}] + * @returns {Peer} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new Peer(/** @type {Partial} */($$parsedSource)); + } +} + +/** + * Status is the snapshot pushed to the frontend, both on request and via the + * periodic "status" event. + */ +export class Status { + /** + * Creates a new Status instance. + * @param {Partial} [$$source = {}] - The source object to create the Status. + */ + constructor($$source = {}) { + if (!("connected" in $$source)) { + /** + * @member + * @type {boolean} + */ + this["connected"] = false; + } + if (!("myIP" in $$source)) { + /** + * @member + * @type {string} + */ + this["myIP"] = ""; + } + if (!("session" in $$source)) { + /** + * active session name, if this tray started it + * @member + * @type {string} + */ + this["session"] = ""; + } + if (!("peers" in $$source)) { + /** + * @member + * @type {Peer[]} + */ + this["peers"] = []; + } + if (!("busy" in $$source)) { + /** + * a connect/send is in flight + * @member + * @type {boolean} + */ + this["busy"] = false; + } + if (!("receiving" in $$source)) { + /** + * a receive listener is running + * @member + * @type {boolean} + */ + this["receiving"] = false; + } + if (!("transfer" in $$source)) { + /** + * latest file-transfer status line + * @member + * @type {string} + */ + this["transfer"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Status instance from a string or object. + * @param {any} [$$source = {}] + * @returns {Status} + */ + static createFrom($$source = {}) { + const $$createField3_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("peers" in $$parsedSource) { + $$parsedSource["peers"] = $$createField3_0($$parsedSource["peers"]); + } + return new Status(/** @type {Partial} */($$parsedSource)); + } +} + +// Private type creation functions +const $$createType0 = Peer.createFrom; +const $$createType1 = $Create.Array($$createType0); diff --git a/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/trayservice.js b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/trayservice.js new file mode 100644 index 0000000..c5a1c1a --- /dev/null +++ b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/trayservice.js @@ -0,0 +1,112 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * TrayService is the bridge exposed to the frontend. It wraps the same commands a + * user would run in a terminal — shelling out to the blindspot binary for the + * operations that need privilege elevation or long-running transfer logic + * (connect / send / receive), and reading the on-disk session state directly for + * everything else. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +/** + * CancelReceive kills a pending `receive` process, if one is running. + * @returns {$CancellablePromise} + */ +export function CancelReceive() { + return $Call.ByID(3816120566); +} + +/** + * Connect runs `blindspot connect -s -p [-n]`, which triggers + * the UAC elevation + daemon launch and blocks until the session is up (or fails). + * It returns the final status line the CLI printed. + * @param {string} session + * @param {string} password + * @param {boolean} isNew + * @returns {$CancellablePromise} + */ +export function Connect(session, password, isNew) { + return $Call.ByID(1698713851, session, password, isNew); +} + +/** + * Disconnect signals the running daemon to stop (the same mechanism as + * `blindspot disconnect`) and waits briefly for it to tear down. + * @returns {$CancellablePromise} + */ +export function Disconnect() { + return $Call.ByID(1465381917); +} + +/** + * GetStatus returns the current session snapshot. + * @returns {$CancellablePromise<$models.Status>} + */ +export function GetStatus() { + return $Call.ByID(3800095171).then(/** @type {($result: any) => any} */(($result) => { + return $$createType0($result); + })); +} + +/** + * MyIP returns this device's virtual IP, derived from the (cleartext) public key. + * Works even when the identity is encrypted at rest. Empty if no identity exists. + * @returns {$CancellablePromise} + */ +export function MyIP() { + return $Call.ByID(3702040876); +} + +/** + * SelectFile opens a native file picker and returns the chosen path (empty if the + * user cancelled). Cancellation is not an error — it returns "" so the frontend + * simply keeps the previous selection and nothing is logged. + * @returns {$CancellablePromise} + */ +export function SelectFile() { + return $Call.ByID(3517471721); +} + +/** + * SendFile runs `blindspot send ` and returns the CLI's final + * line. Blocks for the duration of the transfer. + * @param {string} peerIP + * @param {string} filePath + * @returns {$CancellablePromise} + */ +export function SendFile(peerIP, filePath) { + return $Call.ByID(1125472359, peerIP, filePath); +} + +/** + * StartReceive launches `blindspot receive` in the background so the tray can keep + * serving while it waits for an incoming file. Progress lines are streamed into the + * transfer status and pushed to the frontend. + * @param {boolean} here + * @returns {$CancellablePromise} + */ +export function StartReceive(here) { + return $Call.ByID(1268368826, here); +} + +/** + * Version returns the blindspot version string. + * @returns {$CancellablePromise} + */ +export function Version() { + return $Call.ByID(449630457); +} + +// Private type creation functions +const $$createType0 = $models.Status.createFrom; diff --git a/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js b/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js new file mode 100644 index 0000000..f14e7dc --- /dev/null +++ b/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js @@ -0,0 +1,22 @@ +//@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"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as gui$0 from "../../../../neozmmv/blindspot/internal/gui/models.js"; + +function configure() { + Object.freeze(Object.assign($Create.Events, { + "status": $$createType0, + })); +} + +// Private type creation functions +const $$createType0 = gui$0.Status.createFrom; + +configure(); diff --git a/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts new file mode 100644 index 0000000..8fad456 --- /dev/null +++ b/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -0,0 +1,18 @@ +// 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 type { Events } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import type * as gui$0 from "../../../../neozmmv/blindspot/internal/gui/models.js"; + +declare module "@wailsio/runtime" { + namespace Events { + interface CustomEvents { + "status": gui$0.Status; + } + } +} diff --git a/internal/gui/frontend/bun.lock b/internal/gui/frontend/bun.lock new file mode 100644 index 0000000..2576d28 --- /dev/null +++ b/internal/gui/frontend/bun.lock @@ -0,0 +1,138 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "react-ts-latest", + "dependencies": { + "@wailsio/runtime": "latest", + "react": "^18.2.0", + "react-dom": "^18.2.0", + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^6.0.0", + "typescript": "^5.2.2", + "vite": "^8.0.5", + }, + }, + }, + "packages": { + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], + + "@wailsio/runtime": ["@wailsio/runtime@3.0.0-alpha.97", "", {}, "sha512-alvEG6B0YqW7AcBcNaUWNMD9q8JWqx5AO6BPOZpijBeq4ZjhQxSom3jvvq8Z1mWSMKPEjSvlFirKIh9NLI3ajA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "postcss": ["postcss@8.5.17", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="], + } +} diff --git a/internal/gui/frontend/index.html b/internal/gui/frontend/index.html new file mode 100644 index 0000000..dc36bd3 --- /dev/null +++ b/internal/gui/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Blindspot + + +
+ + + diff --git a/internal/gui/frontend/package.json b/internal/gui/frontend/package.json new file mode 100644 index 0000000..8b2f1ec --- /dev/null +++ b/internal/gui/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "react-ts-latest", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build:dev": "tsc && vite build --minify false --mode development", + "build": "tsc && vite build --mode production", + "preview": "vite preview" + }, + "dependencies": { + "@wailsio/runtime": "latest", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^6.0.0", + "typescript": "^5.2.2", + "vite": "^8.0.5" + } +} diff --git a/internal/gui/frontend/public/Inter-Medium.ttf b/internal/gui/frontend/public/Inter-Medium.ttf new file mode 100644 index 0000000..a01f377 Binary files /dev/null and b/internal/gui/frontend/public/Inter-Medium.ttf differ diff --git a/internal/gui/frontend/public/bg-desktop.jpg b/internal/gui/frontend/public/bg-desktop.jpg new file mode 100644 index 0000000..0fc626b Binary files /dev/null and b/internal/gui/frontend/public/bg-desktop.jpg differ diff --git a/internal/gui/frontend/public/bg-mobile.jpg b/internal/gui/frontend/public/bg-mobile.jpg new file mode 100644 index 0000000..84e9492 Binary files /dev/null and b/internal/gui/frontend/public/bg-mobile.jpg differ diff --git a/internal/gui/frontend/public/blindspot-logo.png b/internal/gui/frontend/public/blindspot-logo.png new file mode 100644 index 0000000..d416f72 Binary files /dev/null and b/internal/gui/frontend/public/blindspot-logo.png differ diff --git a/internal/gui/frontend/public/react.svg b/internal/gui/frontend/public/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/internal/gui/frontend/public/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/gui/frontend/public/style.css b/internal/gui/frontend/public/style.css new file mode 100644 index 0000000..3dd04aa --- /dev/null +++ b/internal/gui/frontend/public/style.css @@ -0,0 +1,418 @@ +:root { + --bg: #0a0b0d; + --surface: #131519; + --surface-2: #1a1d23; + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.14); + + --text: #f4f5f7; + --text-2: #9aa1ac; + --text-3: #656c78; + + --blue: #3b82f6; + --blue-hover: #5896f8; + --blue-press: #2f6fe0; + --blue-soft: rgba(59, 130, 246, 0.12); + --blue-ring: rgba(59, 130, 246, 0.32); + + --green: #22c55e; + --danger: #ef4444; + --danger-soft: rgba(239, 68, 68, 0.1); + + --radius: 12px; + --radius-sm: 9px; + + font-family: 'Segoe UI Variable Text', 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, 'Helvetica Neue', sans-serif; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + overflow-x: hidden; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} +#root { height: 100%; } + +.app { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg); +} + +button { font-family: inherit; } + +/* ── top bar ─────────────────────────────────────────── */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px 18px; + border-bottom: 1px solid var(--border); + --wails-draggable: drag; +} +.brand { display: flex; align-items: center; gap: 10px; } +.logo { + width: 30px; + height: 30px; + object-fit: contain; + display: block; +} +.brand-name { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; } + +.topbar-right { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + /* Exempt the controls from the draggable title bar so clicks register. */ + --wails-draggable: no-drag; +} + +.win-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + margin: 0; + line-height: 0; + flex-shrink: 0; + border-radius: 7px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-2); + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} +.win-btn:hover { color: var(--text); border-color: var(--border-strong); background: var(--surface-2); } +.win-btn svg { display: block; } + +.version { + flex-shrink: 1; + max-width: 140px; + font-size: 12px; + font-weight: 500; + color: var(--text-3); + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── content ─────────────────────────────────────────── */ +.content { + flex: 1; + overflow-y: auto; + padding: 18px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; +} + +/* ── connected: summary ──────────────────────────────── */ +.summary-label { + font-size: 12px; + font-weight: 500; + color: var(--text-3); + margin-bottom: 8px; +} +.ip-value { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 12px; + padding: 0; + background: none; + border: none; + color: inherit; + cursor: pointer; + text-align: left; +} +.ip-text { + font-size: 26px; + font-weight: 600; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} +.copy-btn { + display: inline-flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + font-size: 12px; + font-weight: 500; + color: var(--text-2); + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 7px; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} +.ip-value:hover .copy-btn { color: var(--text); border-color: var(--border-strong); background: var(--surface-2); } + +.summary-meta { + display: flex; + align-items: center; + gap: 9px; + margin-top: 12px; +} +.chip { + font-size: 12px; + font-weight: 500; + color: var(--text); + padding: 3px 9px; + border-radius: 6px; + background: var(--surface-2); + border: 1px solid var(--border); +} +.meta-dim { font-size: 12.5px; color: var(--text-2); } + +/* ── peers ───────────────────────────────────────────── */ +.peers-block { display: flex; flex-direction: column; gap: 10px; } +.section-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.section-title h2 { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); } +.hint-line { font-size: 11.5px; color: var(--text-3); } + +.peer-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.peer { + position: relative; + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.peer:hover { border-color: var(--border-strong); background: var(--surface-2); } +.peer-icon { + display: grid; + place-items: center; + width: 36px; + height: 36px; + flex-shrink: 0; + border-radius: 8px; + color: var(--text-2); + background: var(--surface-2); + border: 1px solid var(--border); +} +.peer:hover .peer-icon { color: var(--blue-hover); } +.peer-info { display: flex; flex-direction: column; min-width: 0; flex: 1; } +.peer-name { + font-size: 14px; + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; +} +.peer-addr { + font-size: 12px; + color: var(--text-2); + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.peer-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } +.peer-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + line-height: 0; + border-radius: 7px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text-2); + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} +.peer-copy:hover { color: var(--text); border-color: var(--border-strong); background: var(--surface); } +.peer-copy svg { display: block; } +.peer-action { + display: grid; + place-items: center; + flex-shrink: 0; + color: var(--text-3); + opacity: 0; + transition: opacity 0.15s, color 0.15s; +} +.peer:hover .peer-action { opacity: 1; color: var(--blue-hover); } + +/* Drop overlay — Wails adds .file-drop-target-active while an OS drag hovers. */ +.peer-drop { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: var(--radius-sm); + background: rgba(20, 30, 55, 0.86); + color: var(--blue-hover); + font-size: 13px; + font-weight: 600; +} +.peer.file-drop-target-active { border-color: var(--blue); background: var(--blue-soft); } +.peer.file-drop-target-active .peer-drop { display: flex; } + +/* ── empty ───────────────────────────────────────────── */ +.empty { + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm); + padding: 22px 16px; + text-align: center; +} +.empty-title { font-size: 13.5px; font-weight: 600; color: var(--text); } +.empty-body { margin: 5px 0 0; font-size: 12.5px; color: var(--text-2); line-height: 1.5; } + +/* ── transfer ────────────────────────────────────────── */ +.transfer { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 13px; + border: 1px solid var(--blue-ring); + border-radius: var(--radius-sm); + background: var(--blue-soft); +} +.transfer-spinner { + width: 14px; height: 14px; flex-shrink: 0; + border: 2px solid rgba(88, 150, 248, 0.35); + border-top-color: var(--blue-hover); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +.transfer-text { font-size: 12.5px; color: var(--text); font-variant-numeric: tabular-nums; word-break: break-word; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── footer actions ──────────────────────────────────── */ +.footer { display: flex; gap: 10px; margin-top: auto; padding-top: 4px; } +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 10px 14px; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.04s; +} +.btn:active { transform: translateY(1px); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-full { width: 100%; } + +.btn-primary { background: var(--blue); color: #fff; box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset, 0 4px 14px rgba(47, 111, 224, 0.28); } +.btn-primary:hover:not(:disabled) { background: var(--blue-hover); } +.btn-primary:active { background: var(--blue-press); } + +.btn-secondary { flex: 1; background: var(--surface); border-color: var(--border); color: var(--text); } +.btn-secondary:hover { background: var(--surface-2); border-color: var(--border-strong); } + +.btn-danger { flex: 1; background: transparent; border-color: var(--border); color: var(--text-2); } +.btn-danger:hover:not(:disabled) { color: var(--danger); border-color: rgba(239, 68, 68, 0.4); background: var(--danger-soft); } + +.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); } + +/* ── connect (disconnected) ──────────────────────────── */ +.connect-card { display: flex; flex-direction: column; } +.connect-title { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.02em; } +.connect-sub { margin: 5px 0 18px; font-size: 13px; color: var(--text-2); } + +.field-label { + font-size: 12px; + font-weight: 500; + color: var(--text-2); + margin-bottom: 7px; +} +.field-label:not(:first-of-type) { margin-top: 14px; } +.optional { color: var(--text-3); font-weight: 400; } + +.input { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--bg); + color: var(--text); + font-size: 13.5px; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} +.input::placeholder { color: var(--text-3); } +.input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); } + +.checkbox { + display: flex; + align-items: center; + gap: 9px; + margin: 16px 0 4px; + font-size: 13px; + color: var(--text-2); + cursor: pointer; + user-select: none; +} +.checkbox input { width: 16px; height: 16px; accent-color: var(--blue); } + +.btn-primary.btn-full { margin-top: 16px; padding: 11px; } +.connect-note { margin: 12px 0 0; font-size: 11.5px; line-height: 1.5; color: var(--text-3); } + +.device-line { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: auto; + padding: 12px 4px 0; + border-top: 1px solid var(--border); +} +.device-k { font-size: 12.5px; color: var(--text-3); } +.device-v { font-size: 13px; font-weight: 500; color: var(--text-2); font-variant-numeric: tabular-nums; } + +/* ── notice ──────────────────────────────────────────── */ +.notice { + position: fixed; + left: 16px; right: 16px; bottom: 16px; + padding: 12px 14px; + border-radius: var(--radius-sm); + background: #16191e; + border: 1px solid var(--border-strong); + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.6); + font-size: 12.5px; + line-height: 1.45; + color: var(--text); + word-break: break-word; + animation: rise 0.18s ease-out; +} +@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } + +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 8px; } +::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.16); } +::-webkit-scrollbar-track { background: transparent; } + +@media (prefers-reduced-motion: reduce) { + * { animation: none !important; transition: none !important; } +} diff --git a/internal/gui/frontend/public/wails.png b/internal/gui/frontend/public/wails.png new file mode 100644 index 0000000..8bdf424 Binary files /dev/null and b/internal/gui/frontend/public/wails.png differ diff --git a/internal/gui/frontend/src/App.tsx b/internal/gui/frontend/src/App.tsx new file mode 100644 index 0000000..b90219f --- /dev/null +++ b/internal/gui/frontend/src/App.tsx @@ -0,0 +1,301 @@ +import { useState, useEffect, useCallback } from 'react' +import { Events, Window } from '@wailsio/runtime' +import { TrayService } from '../bindings/github.com/neozmmv/blindspot/internal/gui' + +interface Peer { + virtualIP: string + publicAddr: string +} + +interface Status { + connected: boolean + myIP: string + session: string + peers: Peer[] + busy: boolean + receiving: boolean + transfer: string +} + +const emptyStatus: Status = { + connected: false, + myIP: '', + session: '', + peers: [], + busy: false, + receiving: false, + transfer: '', +} + +/* Minimal line icons, sized by the surrounding font. */ +const stroke = { + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.75, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, +} +const IconCopy = () => ( + +) +const IconCheck = () => ( + +) +const IconMonitor = () => ( + +) +const IconSend = () => ( + +) +const IconDownload = () => ( + +) +const IconPower = () => ( + +) +/* const IconMinimize = () => ( + +) */ + +function App() { + const [status, setStatus] = useState(emptyStatus) + const [notice, setNotice] = useState('') + const [session, setSession] = useState('') + const [password, setPassword] = useState('') + const [isNew, setIsNew] = useState(false) + const [copied, setCopied] = useState(false) + const [copiedPeer, setCopiedPeer] = useState('') + const [version, setVersion] = useState('') + + const refresh = useCallback(async () => { + try { + setStatus((await TrayService.GetStatus()) as Status) + } catch (e) { + console.error(e) + } + }, []) + + useEffect(() => { + refresh() + TrayService.Version() + .then((v) => { + // Release builds report a clean tag (e.g. "v1.3.10"); dev builds report a Go + // pseudo-version ("v1.3.10-0.-+dirty") — trim that noise. + const clean = String(v) + .replace(/^blindspot\s+/i, '') + .replace(/\+.*$/, '') + .replace(/-0\.\d{12,14}-[0-9a-f]+$/i, '') + setVersion(clean) + }) + .catch(() => {}) + const off = Events.On('status', (ev: any) => { + if (ev?.data) setStatus(ev.data as Status) + }) + return () => { off?.() } + }, [refresh]) + + const flash = (msg: string) => { + setNotice(msg) + window.clearTimeout((flash as any)._t) + ;(flash as any)._t = window.setTimeout(() => setNotice(''), 5000) + } + + const doConnect = async () => { + setNotice('') + try { + const msg = await TrayService.Connect(session, password, isNew) + flash(msg || 'Connected.') + setPassword('') + } catch (e: any) { + flash(String(e?.message ?? e)) + } + } + + const doDisconnect = async () => { + try { flash(await TrayService.Disconnect()) } + catch (e: any) { flash(String(e?.message ?? e)) } + } + + const copyIP = async () => { + if (!status.myIP) return + try { + await navigator.clipboard.writeText(status.myIP) + setCopied(true) + window.setTimeout(() => setCopied(false), 1400) + } catch { /* clipboard unavailable */ } + } + + const copyPeer = async (ip: string) => { + try { + await navigator.clipboard.writeText(ip) + setCopiedPeer(ip) + window.setTimeout(() => setCopiedPeer(''), 1400) + } catch { /* clipboard unavailable */ } + } + + const sendTo = async (peerIP: string) => { + try { + const path = await TrayService.SelectFile() + if (path) await TrayService.SendFile(peerIP, path) + } catch (e: any) { + flash(String(e?.message ?? e)) + } + } + + const doReceive = async () => { + try { await TrayService.StartReceive(false) } + catch (e: any) { flash(String(e?.message ?? e)) } + } + const stopReceive = async () => { + try { await TrayService.CancelReceive() } catch { /* ignore */ } + } + + return ( +
+
+
+ Blindspot + Blindspot +
+
+ {version && {version}} + +
+
+ + {status.connected ? ( +
+
+
Your device
+ +
+ {status.session && {status.session}} + {status.peers.length} {status.peers.length === 1 ? 'peer' : 'peers'} connected +
+
+ +
+
+

Peers

+ Drag a file onto a peer to send it +
+ + {status.peers.length === 0 ? ( +
+
No peers connected
+

Devices that join this network will appear here, ready to receive files.

+
+ ) : ( +
    + {status.peers.map((p) => ( +
  • sendTo(p.virtualIP)} + title={`Send a file to ${p.virtualIP}`} + > + + + {p.virtualIP} + {p.publicAddr} + +
    + + +
    + Drop to send +
  • + ))} +
+ )} +
+ + {status.transfer && ( +
+ + {status.transfer} +
+ )} + +
+ {status.receiving ? ( + + ) : ( + + )} + +
+
+ ) : ( +
+
+

Connect to a network

+

Join a session or create a new encrypted network.

+ + + setSession(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && session) doConnect() }} + autoComplete="off" + autoFocus + /> + + + setPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && session) doConnect() }} + autoComplete="off" + /> + + + + +

Connecting requires administrator access to set up the VPN adapter.

+
+ +
+ Your device IP + {status.myIP || '—'} +
+
+ )} + + {notice &&
{notice}
} +
+ ) +} + +export default App diff --git a/internal/gui/frontend/src/main.tsx b/internal/gui/frontend/src/main.tsx new file mode 100644 index 0000000..3e18231 --- /dev/null +++ b/internal/gui/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + , +) diff --git a/internal/gui/frontend/src/vite-env.d.ts b/internal/gui/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/internal/gui/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/internal/gui/frontend/tsconfig.json b/internal/gui/frontend/tsconfig.json new file mode 100644 index 0000000..ae81ea6 --- /dev/null +++ b/internal/gui/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "bindings"], +} diff --git a/internal/gui/frontend/vite.config.ts b/internal/gui/frontend/vite.config.ts new file mode 100644 index 0000000..d0628a2 --- /dev/null +++ b/internal/gui/frontend/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import wails from "@wailsio/runtime/plugins/vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + server: { + host: "127.0.0.1", + port: Number(process.env.WAILS_VITE_PORT) || 9245, + strictPort: true, + }, + plugins: [react(), wails("./bindings")], +}); diff --git a/internal/gui/icon.png b/internal/gui/icon.png new file mode 100644 index 0000000..ca8090e Binary files /dev/null and b/internal/gui/icon.png differ diff --git a/internal/gui/main.go b/internal/gui/main.go new file mode 100644 index 0000000..8450ef1 --- /dev/null +++ b/internal/gui/main.go @@ -0,0 +1,160 @@ +package gui + +import ( + "embed" + "log" + "runtime" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + "github.com/wailsapp/wails/v3/pkg/icons" +) + +//go:embed all:frontend/dist +var assets embed.FS + +// trayIcon is the tray/menu-bar icon. It must be a PNG (Wails' Windows SetIcon feeds +// the bytes to CreateIconFromResourceEx, which rejects .ico containers). +// +//go:embed icon.png +var trayIcon []byte + +func init() { + // Payload emitted to the frontend whenever the session state changes. + application.RegisterEvent[Status]("status") +} + +// Run launches the system-tray GUI. It is the entry point of the blindspot-tray +// binary; the CLI lives in the separate console-subsystem blindspot binary. +func Run() { + tray := &TrayService{} + + // Declared up front so the SingleInstance callback below (and the tray menu) can + // close over it; assigned once the app exists. + var window *application.WebviewWindow + + app := application.New(application.Options{ + Name: "Blindspot", + Description: "P2P Toolkit: VPN, File Sharing, Chat, and More", + Services: []application.Service{ + application.NewService(tray), + }, + Assets: application.AssetOptions{ + Handler: application.AssetFileServerFS(assets), + }, + Mac: application.MacOptions{ + ActivationPolicy: application.ActivationPolicyAccessory, + }, + // Enforce a single running tray: a second launch exits immediately and asks + // the existing instance to surface its window instead of opening another one. + SingleInstance: &application.SingleInstanceOptions{ + UniqueID: "dev.enzogp.blindspot.tray", + OnSecondInstanceLaunch: func(application.SecondInstanceData) { + if window != nil { + window.Show().Focus() + } + }, + }, + }) + + systemTray := app.SystemTray.New() + + // quitting flips true only when the user chooses Quit, so the close hook below + // knows to let the app actually terminate instead of just hiding the window. + quitting := false + + window = app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Blindspot", + Width: 400, + Height: 600, + Frameless: true, + AlwaysOnTop: true, + Hidden: true, + DisableResize: true, + HideOnEscape: true, + // Stay open when the user alt-tabs or clicks away — the panel only hides via + // the minimize button, Escape, the tray icon, or clicking outside is a no-op. + // It keeps AlwaysOnTop so it floats above other windows while visible. + HideOnFocusLost: false, + BackgroundColour: application.NewRGB(0, 0, 0), + URL: "/", + EnableFileDrop: true, + Windows: application.WindowsWindow{ + HiddenOnTaskbar: true, + }, + }) + + // Drag-and-drop send: each peer card in the frontend is tagged + // data-file-drop-target + data-peer-ip. Dropping OS files onto one fires this + // with the target's attributes, so we send each file to that peer. + // + // Must be OnWindowEvent, NOT RegisterHook: Wails dispatches WindowFilesDropped + // only to eventListeners (OnWindowEvent), never to eventHooks (RegisterHook), so + // a hook here silently never fires — the drag highlight works but no send starts. + window.OnWindowEvent(events.Common.WindowFilesDropped, func(e *application.WindowEvent) { + ctx := e.Context() + files := ctx.DroppedFiles() + details := ctx.DropTargetDetails() + if details == nil || len(files) == 0 { + return + } + peerIP := details.Attributes["data-peer-ip"] + if peerIP == "" { + return + } + go func() { + for _, f := range files { + tray.SendFile(peerIP, f) + } + }() + }) + + // Closing the tray window just hides it — the app keeps running in the tray, + // unless the user picked Quit from the tray menu. + window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + if quitting { + return + } + window.Hide() + e.Cancel() + }) + + // Custom tray icon + hover tooltip. macOS wants a monochrome template icon, so + // keep the built-in there; every other platform gets the Blindspot icon. + if runtime.GOOS == "darwin" { + systemTray.SetTemplateIcon(icons.SystrayMacTemplate) + } else { + systemTray.SetIcon(trayIcon) + } + systemTray.SetTooltip("Blindspot") + + systemTray.AttachWindow(window).WindowOffset(5) + + // Right-click menu on the tray icon. Left-click still toggles the panel; + // setting a menu makes Wails wire right-click to open it (see SystemTray.bind). + trayMenu := application.NewMenu() + trayMenu.Add("Show Blindspot").OnClick(func(_ *application.Context) { + window.Show().Focus() + }) + trayMenu.AddSeparator() + trayMenu.Add("Quit Blindspot").OnClick(func(_ *application.Context) { + quitting = true + app.Quit() + }) + systemTray.SetMenu(trayMenu) + + // Push session status to the frontend on a slow tick so the panel reflects the + // daemon coming up, peers joining/leaving, or the daemon dying, without the UI + // having to poll over the bindings. + go func() { + for { + app.Event.Emit("status", tray.GetStatus()) + time.Sleep(2 * time.Second) + } + }() + + if err := app.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/internal/gui/proc_other.go b/internal/gui/proc_other.go new file mode 100644 index 0000000..4ef8a16 --- /dev/null +++ b/internal/gui/proc_other.go @@ -0,0 +1,22 @@ +//go:build !windows + +package gui + +import ( + "os" + "os/exec" + "syscall" +) + +// isProcessAlive reports whether the process with the given PID is still running, +// using signal 0 (the POSIX liveness probe). +func isProcessAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} + +// hideConsole is a no-op off Windows. +func hideConsole(cmd *exec.Cmd) {} diff --git a/internal/gui/proc_windows.go b/internal/gui/proc_windows.go new file mode 100644 index 0000000..16caf85 --- /dev/null +++ b/internal/gui/proc_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package gui + +import ( + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +// isProcessAlive reports whether the process with the given PID is still running. +// Mirrors cmd/pid_windows.go so tray status matches what the CLI reports. +func isProcessAlive(pid int) bool { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + defer windows.CloseHandle(h) + var exitCode uint32 + if err := windows.GetExitCodeProcess(h, &exitCode); err != nil { + return false + } + return exitCode == 259 // STILL_ACTIVE +} + +// hideConsole prevents a console window from flashing when the tray shells out to +// the blindspot CLI binary. +func hideConsole(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW +} diff --git a/internal/gui/trayservice.go b/internal/gui/trayservice.go new file mode 100644 index 0000000..9243652 --- /dev/null +++ b/internal/gui/trayservice.go @@ -0,0 +1,427 @@ +package gui + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + + bstun "github.com/neozmmv/blindspot/internal/tun" + "github.com/neozmmv/blindspot/internal/utils" +) + +// Session state files written by the connect daemon (see cmd/connect.go). The tray +// reads them directly for status rather than shelling out on every tick. +func sessionPIDFile() string { return filepath.Join(utils.GetBlindspotDir(), "session.pid") } +func sessionStopFile() string { return filepath.Join(utils.GetBlindspotDir(), "session.stop") } +func peersFile() string { return filepath.Join(utils.GetBlindspotDir(), "peers.json") } + +// Peer is one connected member of the active session. +type Peer struct { + VirtualIP string `json:"virtualIP"` + PublicAddr string `json:"publicAddr"` +} + +// Status is the snapshot pushed to the frontend, both on request and via the +// periodic "status" event. +type Status struct { + Connected bool `json:"connected"` + MyIP string `json:"myIP"` + Session string `json:"session"` // active session name, if this tray started it + Peers []Peer `json:"peers"` + Busy bool `json:"busy"` // a connect/send is in flight + Receiving bool `json:"receiving"` // a receive listener is running + Transfer string `json:"transfer"` // latest file-transfer status line +} + +// TrayService is the bridge exposed to the frontend. It wraps the same commands a +// user would run in a terminal — shelling out to the blindspot binary for the +// operations that need privilege elevation or long-running transfer logic +// (connect / send / receive), and reading the on-disk session state directly for +// everything else. +type TrayService struct { + mu sync.Mutex + busy bool + transfer string + session string // the session name this tray connected to, if any + recvCmd *exec.Cmd // running `receive` process, if any + recvCancelled bool // set when the user stops the receive, so we clear rather than report +} + +// cliExe returns the path to the blindspot CLI binary the tray shells out to. The +// tray ships alongside the CLI, so it prefers a blindspot(.exe) sitting next to +// itself and falls back to whatever is on PATH. +func cliExe() string { + name := "blindspot" + if runtime.GOOS == "windows" { + name = "blindspot.exe" + } + if exe, err := os.Executable(); err == nil { + sibling := filepath.Join(filepath.Dir(exe), name) + if _, err := os.Stat(sibling); err == nil { + return sibling + } + } + return name // resolved via PATH +} + +func (s *TrayService) sessionRunning() bool { + data, err := os.ReadFile(sessionPIDFile()) + if err != nil { + return false + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || !isProcessAlive(pid) { + // Stale files from an unclean shutdown — clear them, matching the CLI. + os.Remove(sessionPIDFile()) + os.Remove(sessionStopFile()) + os.Remove(peersFile()) + return false + } + return true +} + +func (s *TrayService) readPeers() []Peer { + data, err := os.ReadFile(peersFile()) + if err != nil || len(data) == 0 { + return []Peer{} + } + // The daemon writes cmd.PeerEntry ({virtual_ip, public_addr}); decode into the + // shape the frontend consumes. + var raw []struct { + VirtualIP string `json:"virtual_ip"` + PublicAddr string `json:"public_addr"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return []Peer{} + } + peers := make([]Peer, 0, len(raw)) + for _, p := range raw { + peers = append(peers, Peer{VirtualIP: p.VirtualIP, PublicAddr: p.PublicAddr}) + } + return peers +} + +// MyIP returns this device's virtual IP, derived from the (cleartext) public key. +// Works even when the identity is encrypted at rest. Empty if no identity exists. +func (s *TrayService) MyIP() string { + publicKey, err := utils.ReadPublicKey() + if err != nil { + return "" + } + return bstun.VirtualIPv4(publicKey) +} + +// GetStatus returns the current session snapshot. +func (s *TrayService) GetStatus() Status { + connected := s.sessionRunning() + var peers []Peer + if connected { + peers = s.readPeers() + } else { + peers = []Peer{} + } + s.mu.Lock() + busy, transfer, receiving, session := s.busy, s.transfer, s.recvCmd != nil, s.session + s.mu.Unlock() + if !connected { + session = "" // a stale session name shouldn't outlive the connection + } + return Status{ + Connected: connected, + MyIP: s.MyIP(), + Session: session, + Peers: peers, + Busy: busy, + Receiving: receiving, + Transfer: transfer, + } +} + +// emit pushes a fresh status snapshot to the frontend immediately (used after an +// action so the UI doesn't wait for the next poll tick). +func (s *TrayService) emit() { + if app := application.Get(); app != nil { + app.Event.Emit("status", s.GetStatus()) + } +} + +func (s *TrayService) setBusy(b bool) { + s.mu.Lock() + s.busy = b + s.mu.Unlock() + s.emit() +} + +func (s *TrayService) setTransfer(msg string) { + s.mu.Lock() + s.transfer = msg + s.mu.Unlock() + s.emit() +} + +// clearTransferAfter blanks the transfer line after d — but only if it still shows +// msg and no receive is running, so a newer transfer's status is never wiped. +func (s *TrayService) clearTransferAfter(msg string, d time.Duration) { + if msg == "" { + return + } + go func() { + time.Sleep(d) + s.mu.Lock() + cleared := s.transfer == msg && s.recvCmd == nil + if cleared { + s.transfer = "" + } + s.mu.Unlock() + if cleared { + s.emit() + } + }() +} + +// Connect runs `blindspot connect -s -p [-n]`, which triggers +// the UAC elevation + daemon launch and blocks until the session is up (or fails). +// It returns the final status line the CLI printed. +func (s *TrayService) Connect(session, password string, isNew bool) (string, error) { + session = strings.TrimSpace(session) + if session == "" { + return "", fmt.Errorf("session name is required") + } + if isNew && len(password) < 8 { + return "", fmt.Errorf("a new session needs a password of at least 8 characters") + } + if s.sessionRunning() { + return "", fmt.Errorf("already connected — disconnect first") + } + + args := []string{"connect", "-s", session} + if password != "" { + args = append(args, "-p", password) + } + if isNew { + args = append(args, "-n") + } + + s.setBusy(true) + defer s.setBusy(false) + + out, err := s.runCLI(args...) + if err == nil && s.sessionRunning() { + s.mu.Lock() + s.session = session + s.mu.Unlock() + } + s.emit() + if err != nil { + if out != "" { + return "", fmt.Errorf("%s", out) + } + return "", err + } + return out, nil +} + +// Disconnect signals the running daemon to stop (the same mechanism as +// `blindspot disconnect`) and waits briefly for it to tear down. +func (s *TrayService) Disconnect() (string, error) { + s.mu.Lock() + s.session = "" + s.mu.Unlock() + if !s.sessionRunning() { + return "No active session.", nil + } + if err := os.WriteFile(sessionStopFile(), []byte("stop"), 0600); err != nil { + return "", fmt.Errorf("could not signal daemon: %w", err) + } + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(sessionPIDFile()); os.IsNotExist(err) { + s.emit() + return "Disconnected.", nil + } + time.Sleep(200 * time.Millisecond) + } + // Daemon didn't exit cleanly — remove leftovers, matching the CLI. + os.Remove(sessionStopFile()) + os.Remove(sessionPIDFile()) + os.Remove(peersFile()) + s.emit() + return "Session did not stop cleanly; cleaned up.", nil +} + +// SelectFile opens a native file picker and returns the chosen path (empty if the +// user cancelled). Cancellation is not an error — it returns "" so the frontend +// simply keeps the previous selection and nothing is logged. +func (s *TrayService) SelectFile() (string, error) { + app := application.Get() + if app == nil { + return "", fmt.Errorf("application not ready") + } + path, err := app.Dialog.OpenFile().CanChooseFiles(true).SetTitle("Select a file to send").PromptForSingleSelection() + if err != nil { + return "", nil // user dismissed the dialog + } + return path, nil +} + +// SendFile runs `blindspot send ` and returns the CLI's final +// line. Blocks for the duration of the transfer. +func (s *TrayService) SendFile(peerIP, filePath string) (string, error) { + peerIP = strings.TrimSpace(peerIP) + filePath = strings.TrimSpace(filePath) + if peerIP == "" || filePath == "" { + return "", fmt.Errorf("a peer IP and a file are both required") + } + if _, err := os.Stat(filePath); err != nil { + return "", fmt.Errorf("file not found: %s", filePath) + } + + s.setBusy(true) + s.setTransfer(fmt.Sprintf("Sending %s to %s…", filepath.Base(filePath), peerIP)) + defer s.setBusy(false) + + out, err := s.runCLI("send", peerIP, filePath) + last := lastLine(out) + if err != nil { + if last == "" { + last = "Send failed." + } + s.setTransfer(last) + s.clearTransferAfter(last, 8*time.Second) + return "", fmt.Errorf("%s", last) + } + s.setTransfer(last) + s.clearTransferAfter(last, 8*time.Second) + return last, nil +} + +// StartReceive launches `blindspot receive` in the background so the tray can keep +// serving while it waits for an incoming file. Progress lines are streamed into the +// transfer status and pushed to the frontend. +func (s *TrayService) StartReceive(here bool) error { + s.mu.Lock() + if s.recvCmd != nil { + s.mu.Unlock() + return fmt.Errorf("already waiting to receive") + } + + args := []string{"receive"} + if here { + args = append(args, "--here") + } + cmd := exec.Command(cliExe(), args...) + hideConsole(cmd) + stdout, err := cmd.StdoutPipe() + if err != nil { + s.mu.Unlock() + return err + } + cmd.Stderr = cmd.Stdout + if err := cmd.Start(); err != nil { + s.mu.Unlock() + return err + } + s.recvCmd = cmd + s.transfer = "Waiting for a file…" + s.mu.Unlock() + s.emit() + + // Stream the CLI output. It emits progress with carriage returns, so split on + // both \r and \n and surface the most recent non-empty line. + go func() { + scanner := bufio.NewScanner(stdout) + scanner.Split(scanLinesOrCR) + for scanner.Scan() { + if line := strings.TrimSpace(scanner.Text()); line != "" { + s.setTransfer(line) + } + } + _ = scanner.Err() + cmd.Wait() + s.mu.Lock() + s.recvCmd = nil + cancelled := s.recvCancelled + s.recvCancelled = false + result := s.transfer + // A user stop, or ending while still just waiting, leaves no useful result — + // clear the line so it doesn't linger. A real outcome (file saved, or an + // error mid-transfer) stays briefly, then auto-clears below. + if cancelled || strings.HasPrefix(result, "Waiting") { + s.transfer = "" + result = "" + } + s.mu.Unlock() + s.emit() + s.clearTransferAfter(result, 8*time.Second) + }() + return nil +} + +// CancelReceive kills a pending `receive` process, if one is running. +func (s *TrayService) CancelReceive() error { + s.mu.Lock() + cmd := s.recvCmd + if cmd != nil { + s.recvCancelled = true + } + s.mu.Unlock() + if cmd == nil || cmd.Process == nil { + return nil + } + return cmd.Process.Kill() +} + +// Version returns the blindspot version string. +func (s *TrayService) Version() string { + out, err := s.runCLI("version") + if err != nil || out == "" { + return "blindspot" + } + return lastLine(out) +} + +// runCLI invokes a blindspot subcommand on this same binary and returns its +// combined, trimmed output. +func (s *TrayService) runCLI(args ...string) (string, error) { + cmd := exec.Command(cliExe(), args...) + hideConsole(cmd) + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func lastLine(s string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if l := strings.TrimSpace(lines[i]); l != "" { + return l + } + } + return "" +} + +// scanLinesOrCR is a bufio.SplitFunc that breaks tokens on either newline or +// carriage return, so progress-bar updates (which use \r) surface as they arrive. +func scanLinesOrCR(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + for i, b := range data { + if b == '\n' || b == '\r' { + return i + 1, data[:i], nil + } + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil +} diff --git a/main.go b/main.go index 4ed9a57..0396883 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,16 @@ package main import ( + "os" + "github.com/neozmmv/blindspot/cmd" ) +// blindspot is the console-subsystem CLI. The system-tray GUI is a separate, +// GUI-subsystem binary (cmd/tray → blindspot-tray.exe) so that running blindspot +// from a terminal behaves like an ordinary command-line tool. func main() { - cmd.Execute() + if err := cmd.Execute(); err != nil { + os.Exit(1) + } } diff --git a/public/BLINDSPOT.ico b/public/BLINDSPOT.ico new file mode 100644 index 0000000..09ce973 Binary files /dev/null and b/public/BLINDSPOT.ico differ diff --git a/public/BLINDSPOT_tray.png b/public/BLINDSPOT_tray.png new file mode 100644 index 0000000..ca8090e Binary files /dev/null and b/public/BLINDSPOT_tray.png differ diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 59f32e9..b3731e3 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,10 +1,35 @@ $ErrorActionPreference = "Stop" -$Url = "https://github.com/neozmmv/blindspot/releases/latest/download/blindspot.exe" -$Dest = "$env:LOCALAPPDATA\Microsoft\WindowsApps\blindspot.exe" +$Base = "https://github.com/neozmmv/blindspot/releases/latest/download" +$RawIcon = "https://raw.githubusercontent.com/neozmmv/blindspot/master/public/BLINDSPOT.ico" +# WindowsApps is already on PATH; both binaries live here, side by side, so the tray +# can find and shell out to the CLI. +$Dir = "$env:LOCALAPPDATA\Microsoft\WindowsApps" -Write-Host "Downloading latest blindspot..." -Invoke-WebRequest -Uri $Url -OutFile $Dest +Write-Host "Downloading Blindspot (CLI + tray)..." +Invoke-WebRequest -Uri "$Base/blindspot.exe" -OutFile "$Dir\blindspot.exe" +Invoke-WebRequest -Uri "$Base/blindspot-tray.exe" -OutFile "$Dir\blindspot-tray.exe" -Write-Host "Installed to $Dest" -Write-Host "Run 'blindspot' from any terminal." +# Icon for the Start Menu shortcut. The binaries carry no embedded icon by design, +# so fetch the .ico separately; the shortcut still works if this fails. +$IconPath = "$Dir\blindspot.ico" +try { + Invoke-WebRequest -Uri $RawIcon -OutFile $IconPath -ErrorAction Stop +} catch { + $IconPath = $null +} + +# Start Menu shortcut that launches the tray. +$Shortcut = Join-Path ([Environment]::GetFolderPath("Programs")) "Blindspot.lnk" +$Shell = New-Object -ComObject WScript.Shell +$Link = $Shell.CreateShortcut($Shortcut) +$Link.TargetPath = "$Dir\blindspot-tray.exe" +$Link.WorkingDirectory = $Dir +$Link.Description = "Blindspot - P2P VPN tray" +if ($IconPath) { $Link.IconLocation = "$IconPath,0" } +$Link.Save() + +Write-Host "" +Write-Host "Installed to $Dir" +Write-Host " - CLI: run 'blindspot' from any terminal" +Write-Host " - Tray: launch 'Blindspot' from the Start Menu" diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..6e67552 --- /dev/null +++ b/wails.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "blindspot-tray", + "outputfilename": "blindspot-tray", + "frontend:install": "bun install", + "frontend:build": "bun run build", + "frontend:dev:watcher": "bun run dev", + "frontend:dev:serverUrl": "auto", + "frontend:dir": "internal/gui/frontend" +}