Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3dd43e1
refactor: move engine + CLI into internal/core so the desktop app can…
meabed Jun 1, 2026
c354eec
feat: add core.Controller — in-process start/stop/status for embedding
meabed Jun 1, 2026
a8bafae
refactor: make core a public package (out of internal) so the desktop…
meabed Jun 1, 2026
0969054
feat(desktop): tray-first Wails v3 app driving core.Controller in-pro…
meabed Jun 1, 2026
45e98bb
feat: show each service URL in the discovered log
meabed Jun 1, 2026
a501767
feat(desktop): rich webview panel for the menu bar
meabed Jun 1, 2026
6c051da
feat(desktop): richer panel, Settings/About window, app icon + Hallma…
meabed Jun 1, 2026
2a01f4c
feat(desktop): translucent panel, process stats, setup state, hide-on…
meabed Jun 1, 2026
1abbc21
fix(desktop): reopenable settings, scannable cards, pointer cursors, …
meabed Jun 1, 2026
ed75b80
feat(desktop): per-service action menu, no-flicker list, HH:MM uptime…
meabed Jun 1, 2026
03812bf
feat(desktop): local + proxy URL menu items, kill confirmation, HH:MM…
meabed Jun 1, 2026
2196c29
ci(desktop): release workflow — build .dmg/.zip/.tar.gz per OS and pu…
meabed Jun 1, 2026
a21a2e4
fix(desktop): settings window reopens reliably (recreate on demand)
meabed Jun 1, 2026
5f1147f
docs: professional desktop download page, app icon, README + website
meabed Jun 1, 2026
6dcac74
Merge master into feat/desktop-app
meabed Jun 1, 2026
fddcba9
feat(desktop): About tab — matching logo, Report an issue + Check for…
meabed Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: desktop-release

# Build the Wails desktop app on each OS and publish a GitHub release with the
# installers. Manual: run from the Actions tab with a version (e.g. 0.1.0).
# The desktop app is a separate Go module under desktop/ — this pipeline is
# independent of the CLI's semantic-release.

on:
workflow_dispatch:
inputs:
version:
description: "Version, e.g. 0.1.0"
required: true
type: string

permissions:
contents: write

jobs:
build:
name: build (${{ matrix.label }})
strategy:
fail-fast: false
matrix:
include:
- { os: macos-latest, label: macos-arm64 }
- { os: macos-13, label: macos-amd64 }
- { os: windows-latest, label: windows-amd64 }
- { os: ubuntu-latest, label: linux-amd64 }
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
working-directory: desktop
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: "stable"

- name: Linux webview deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev

- name: Build and package
env:
VERSION: ${{ inputs.version }}
LABEL: ${{ matrix.label }}
CGO_ENABLED: "1"
run: |
set -euo pipefail
LD="-s -w -X github.com/meabed/tailscale-proxy/core.Version=${VERSION}"
mkdir -p ../dist
case "$LABEL" in
macos-*)
go build -trimpath -ldflags "$LD" -o tailscale-proxy .
bash scripts/make-macos-dmg.sh "$VERSION" tailscale-proxy \
"../dist/TailscaleProxy-${VERSION}-${LABEL}.dmg"
;;
windows-*)
go build -trimpath -ldflags "$LD -H windowsgui" -o TailscaleProxy.exe .
7z a "../dist/TailscaleProxy-${VERSION}-${LABEL}.zip" TailscaleProxy.exe
;;
linux-*)
go build -trimpath -ldflags "$LD" -o tailscale-proxy .
tar czf "../dist/TailscaleProxy-${VERSION}-${LABEL}.tar.gz" tailscale-proxy
;;
esac
ls -la ../dist

- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.label }}
path: dist/*
if-no-files-found: error

release:
name: Publish release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- run: ls -la dist
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: desktop-v${{ inputs.version }}
name: Tailscale Proxy desktop v${{ inputs.version }}
files: dist/*
body: |
**Tailscale Proxy** menu-bar app v${{ inputs.version }} — a tray UI over the `tsp` engine.

| Platform | Download |
| --- | --- |
| macOS (Apple Silicon) | `TailscaleProxy-${{ inputs.version }}-macos-arm64.dmg` |
| macOS (Intel) | `TailscaleProxy-${{ inputs.version }}-macos-amd64.dmg` |
| Windows | `TailscaleProxy-${{ inputs.version }}-windows-amd64.zip` |
| Linux | `TailscaleProxy-${{ inputs.version }}-linux-amd64.tar.gz` |

### Install
- **macOS** — open the `.dmg`, drag **Tailscale Proxy** to Applications. It's unsigned, so the first launch needs: right-click the app → **Open** (or `xattr -dr com.apple.quarantine "/Applications/Tailscale Proxy.app"`). It lives in the **menu bar** (no Dock icon).
- **Windows** — unzip and run `TailscaleProxy.exe`. SmartScreen may warn (unsigned): **More info → Run anyway**.
- **Linux** — `tar xzf` and run `./tailscale-proxy`. Needs GTK3 + WebKit2GTK 4.1 (`sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0`).

Requires [Tailscale](https://tailscale.com/download) installed and signed in — the app shows setup steps if it isn't.
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,17 +100,24 @@ Supported: **macOS, Linux, Windows, WSL** (amd64 + arm64).
Update later with **`tsp update`** — it self-updates a standalone binary, or prints
`brew upgrade tsp` / `npm i -g tailscale-proxy@latest` for managed installs.

### Desktop app (menu bar / tray)
---

Prefer clicking to typing? There's a tray-first desktop app (Wails) that drives the
same engine in-process — start/stop, switch Funnel/Serve, open service URLs, and
**start at login** — sharing the same `~/.tailscale-proxy/config.json` as the CLI.
## Desktop app

```bash
cd desktop && go build -o tsp-app . && ./tsp-app
```
<img src="website/public/app-icon.svg" align="right" width="96" alt="Tailscale Proxy app icon" />

Prefer clicking to typing? There's a native **menu-bar app** for **macOS, Windows,
and Linux** — start/stop the proxy, watch each dev server (cpu · memory · uptime),
switch Funnel/Serve, and open/copy/kill services, all without a terminal. It runs
the same engine as the CLI and shares the same `~/.tailscale-proxy/config.json`.

**[⬇ Download](https://github.com/meabed/tailscale-proxy/releases)** — pick the
latest **`desktop-v…`** release · **[Docs & screenshots](https://tailscaleproxy.vercel.app/desktop)**

<img src="website/public/desktop-panel.png" width="330" alt="Tailscale Proxy menu-bar panel" />

Build/packaging details: [`desktop/README.md`](desktop/README.md).
Build from source: `cd desktop && go build -o tsp-app . && ./tsp-app` (see
[`desktop/README.md`](desktop/README.md)).

---

Expand Down
21 changes: 21 additions & 0 deletions core/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,27 @@ func DefaultConfig() Config { return defaultConfig() }
// KnownRuntimes returns the labels of the web runtimes discovery recognizes.
func KnownRuntimes() []string { return knownRuntimeLabels() }

// UpdateInfo reports the running version vs the latest GitHub release.
type UpdateInfo struct {
Current string `json:"current"`
Latest string `json:"latest"`
HasUpdate bool `json:"hasUpdate"`
Err string `json:"err"`
}

// CheckUpdate queries the latest GitHub release and compares it to Version.
func CheckUpdate() UpdateInfo {
u := UpdateInfo{Current: Version}
latest, err := latestVersion()
if err != nil {
u.Err = err.Error()
return u
}
u.Latest = latest
u.HasUpdate = Version != "dev" && normalizeVer(latest) != normalizeVer(Version)
return u
}

// TailscaleHealth reports whether the `tailscale` CLI is present and logged in.
type TailscaleHealth struct {
Installed bool `json:"installed"`
Expand Down
22 changes: 14 additions & 8 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ A tray-first desktop wrapper around the `tsp` engine. It drives
bar can start/stop the proxy, switch Funnel/Serve, open service URLs, toggle
start-at-login, and edit the shared `~/.tailscale-proxy/config.json`.

> **Download a build:** [Releases](https://github.com/meabed/tailscale-proxy/releases)
> (newest `desktop-v…`) · [docs page](https://tailscaleproxy.vercel.app/desktop).

Built with [Wails v3](https://v3alpha.wails.io) (Go + native webview). Separate Go
module so the CLI module stays dependency-free; it imports `core` via a local
`replace` directive.
Expand Down Expand Up @@ -47,17 +50,20 @@ go build -o tsp-app . # builds a native binary (CGO links the system webview)
`go run .` works too. The proxy needs Tailscale set up exactly like the CLI — run
`tsp doctor` (or the CLI) first if the menu shows it stopped with an error.

## Package it (.app / .dmg / .msi / .deb)
## Release builds

For a signed, bundled app, use the Wails v3 toolchain:
Installers are produced by the **`desktop-release`** GitHub workflow
([`.github/workflows/desktop-release.yml`](../.github/workflows/desktop-release.yml)).
Run it from the Actions tab with a version (e.g. `0.1.0`); it builds on macOS
(arm64 + Intel), Windows, and Linux runners, packages each
(`make-macos-dmg.sh` → `.dmg`, windowsgui `.exe` → `.zip`, Linux `.tar.gz`), and
publishes a `desktop-v<version>` GitHub release.

```bash
go install github.com/wailsapp/wails/v3/cmd/wails3@latest
wails3 build # see https://v3alpha.wails.io for packaging + signing
```
Locally on one platform you can also `go build` (above) or use the Wails toolchain
(`go install github.com/wailsapp/wails/v3/cmd/wails3@latest && wails3 build`).

Packaging config (icons, bundle identifier, signing, notarization) and CI wiring
are the next step — see the repo's `AGENT.md` for status.
Builds are currently **unsigned** — code-signing + notarization (macOS) and an
Authenticode cert (Windows) are the next step.

## Layout

Expand Down
59 changes: 55 additions & 4 deletions desktop/assets/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@
.about .ver { color: var(--fg-2); margin-top: 4px; font: 12px var(--mono); }
.about p { color: var(--fg-2); max-width: 360px; margin: 16px auto; line-height: 1.55; }
.links { display: flex; gap: 20px; justify-content: center; margin-top: 14px; }
.about-actions { display: flex; gap: 8px; justify-content: center; margin-top: 18px; }
.about-btn { display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--hair-2); background: var(--raise); color: var(--fg); border-radius: var(--r-sm); padding: 8px 14px; font-weight: 550; font-size: 12.5px; cursor: pointer; transition: all var(--dur) var(--ease); }
.about-btn svg { width: 14px; height: 14px; opacity: .85; }
.about-btn:hover { background: var(--raise-2); border-color: var(--accent); }
.about-btn:active { transform: scale(0.97); }
.about-btn[disabled] { opacity: .6; cursor: default; }
.upd { margin-top: 14px; min-height: 18px; font-size: 12px; color: var(--fg-2); }
.upd a { font-weight: 600; }
.upd .ok2 { color: var(--on); font-weight: 600; }
.legal { color: var(--fg-3); font-size: 11px; margin-top: 26px; line-height: 1.5; }
.legal code { font: 11px var(--mono); color: var(--fg-2); }

Expand Down Expand Up @@ -124,10 +133,14 @@
<div class="pane" id="pane-about">
<div class="about">
<svg class="logo" viewBox="0 0 88 88" fill="none">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5b8cff"/><stop offset="1" stop-color="#2f50c8"/></linearGradient></defs>
<rect x="3" y="3" width="82" height="82" rx="21" fill="url(#g)"/>
<circle cx="44" cy="44" r="19" fill="none" stroke="#fff" stroke-width="5"/>
<circle cx="44" cy="44" r="6.5" fill="#fff"/>
<defs><linearGradient id="g" x1="14" y1="10" x2="74" y2="78" gradientUnits="userSpaceOnUse"><stop stop-color="#6E9BFF"/><stop offset="0.55" stop-color="#3E63DD"/><stop offset="1" stop-color="#2A47C0"/></linearGradient></defs>
<rect x="3" y="3" width="82" height="82" rx="22" fill="url(#g)"/>
<g stroke="#fff" stroke-width="3.8" stroke-linecap="round">
<path d="M33 44 L58 29"/><path d="M33 44 L61 44"/><path d="M33 44 L58 59"/>
</g>
<g fill="#fff">
<circle cx="33" cy="44" r="6.6"/><circle cx="59" cy="29" r="4.4"/><circle cx="61" cy="44" r="4.4"/><circle cx="59" cy="59" r="4.4"/>
</g>
</svg>
<h1 id="name">Tailscale Proxy</h1>
<div class="ver" id="ver">version —</div>
Expand All @@ -137,6 +150,17 @@ <h1 id="name">Tailscale Proxy</h1>
<a href="#" onclick="open_('https://github.com/meabed/tailscale-proxy');return false">GitHub ↗</a>
<a href="#" onclick="open_('https://www.npmjs.com/package/tailscale-proxy');return false">npm ↗</a>
</div>
<div class="about-actions">
<button class="about-btn" onclick="open_('https://github.com/meabed/tailscale-proxy/issues/new')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 9v4M12 17h.01"/><circle cx="12" cy="12" r="9"/></svg>
Report an issue
</button>
<button class="about-btn" id="updBtn" onclick="checkUpdate()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4M21 3v6h-6"/></svg>
Check for updates
</button>
</div>
<div class="upd" id="upd"></div>
<div class="legal">MIT © Mohamed Meabed · shares <code>~/.tailscale-proxy/config.json</code> with the <code>tsp</code> CLI</div>
</div>
</div>
Expand Down Expand Up @@ -168,6 +192,33 @@ <h1 id="name">Tailscale Proxy</h1>
if (id === "hideDock") post("/api/prefs", { hideDock: on });
}
function open_(url) { post("/api/open", { url }); }
const RELEASES = "https://github.com/meabed/tailscale-proxy/releases";

function relLink(label) {
const a = document.createElement("a");
a.href = "#"; a.textContent = label;
a.addEventListener("click", (e) => { e.preventDefault(); open_(RELEASES); });
return a;
}
async function checkUpdate() {
const el = $("upd"), btn = $("updBtn");
btn.disabled = true; el.textContent = "Checking…";
try {
const u = await (await fetch("/api/checkupdate", { headers: HDR })).json();
el.textContent = "";
if (u.err) {
el.textContent = "Couldn't check for updates.";
} else if (u.hasUpdate) {
el.append(document.createTextNode("Update available: "));
const b = document.createElement("b"); b.textContent = u.latest; el.append(b, document.createTextNode(" · "), relLink("Download ↗"));
} else if (u.current === "dev") {
el.append(document.createTextNode("Development build · latest release " + (u.latest || "—") + " · "), relLink("Releases ↗"));
} else {
const s = document.createElement("span"); s.className = "ok2"; s.textContent = "You're on the latest version (" + u.current + ")"; el.append(s);
}
} catch (e) { el.textContent = "Couldn't check for updates."; }
btn.disabled = false;
}

async function load() {
const r = await (await fetch("/api/config", { headers: HDR })).json();
Expand Down
3 changes: 3 additions & 0 deletions desktop/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,9 @@ func (u *ui) startDashboard() (string, error) {
}
w.WriteHeader(http.StatusNoContent)
}))
mux.HandleFunc("/api/checkupdate", auth(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.CheckUpdate())
}))
mux.HandleFunc("/api/settings", auth(func(w http.ResponseWriter, r *http.Request) {
u.showSettings()
w.WriteHeader(http.StatusNoContent)
Expand Down
38 changes: 25 additions & 13 deletions desktop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ type ui struct {
settings *application.WebviewWindow
ctl *core.Controller

mu sync.Mutex
cfg core.Config
token string
mu sync.Mutex
cfg core.Config
token string
settingsURL string

dmu sync.Mutex // guards the status caches below
seen map[string]time.Time
Expand Down Expand Up @@ -76,15 +77,9 @@ func main() {
// Dismiss the panel when the user clicks away from it.
u.panel.OnWindowEvent(events.Common.WindowLostFocus, func(*application.WindowEvent) { u.hidePanel() })

u.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings", Title: appName + " Settings", URL: base + "/settings",
Width: 560, Height: 660, Hidden: true, BackgroundColour: bgColour,
})
// Hide (not destroy) on close so it can be reopened.
u.settings.OnWindowEvent(events.Common.WindowClosing, func(e *application.WindowEvent) {
e.Cancel()
u.settings.Hide()
})
// The settings window is created on demand (see showSettings) so it always
// reopens cleanly after the user closes it.
u.settingsURL = base + "/settings"

u.ctl.OnChange(func() { application.InvokeAsync(u.updateIcon) })
u.updateIcon()
Expand Down Expand Up @@ -116,7 +111,24 @@ func (u *ui) updateIcon() {
}
}

func (u *ui) showSettings() { application.InvokeAsync(func() { u.settings.Show() }) }
// showSettings opens the settings window, (re)creating it if it was closed.
// Closing the window destroys it in Wails, so we clear the reference on close and
// build a fresh one next time — this is what makes "open → close → open" reliable.
func (u *ui) showSettings() {
application.InvokeAsync(func() {
if u.settings == nil {
u.settings = u.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings", Title: appName + " Settings", URL: u.settingsURL,
Width: 560, Height: 660, Hidden: true, BackgroundColour: bgColour,
})
u.settings.OnWindowEvent(events.Common.WindowClosing, func(*application.WindowEvent) {
u.settings = nil
})
}
u.settings.Show()
u.settings.Focus()
})
}

func (u *ui) hidePanel() { application.InvokeAsync(func() { u.panel.Hide() }) }

Expand Down
Loading
Loading