diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..a09a09a --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,39 @@ +# Publishes the privacy policy (and any other static pages under docs/store/site) +# to GitHub Pages. One-time setup: repo Settings > Pages > Source = "GitHub Actions". +# After that, this deploys on every push to main that touches the site folder. +# +# Public URL: https://paulocorcino.github.io/devtunnel_gui/ +name: Deploy Pages + +on: + push: + branches: [main] + paths: + - "docs/store/site/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; don't cancel an in-progress run. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/store/site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/Cargo.toml b/Cargo.toml index 2302b31..ddd7c65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,9 +28,13 @@ log = "0.4" tunnels = { git = "https://github.com/microsoft/dev-tunnels", features = ["connections", "vendored-openssl"], optional = true } tokio = { version = "1", features = ["full"], optional = true } env_logger = { version = "0.11", optional = true } -# Blocking HTTP client for the health probe (issue #4). rustls avoids a native -# OpenSSL dependency for the probe itself. Optional: only pulled by `hosting`. -ureq = { version = "2", default-features = false, features = ["tls"], optional = true } +# Blocking HTTP client. Used by the startup update checker (GitHub Releases) in +# every build, and by the health probe in the `hosting` build. rustls avoids a +# native OpenSSL dependency, keeping the default build light. +ureq = { version = "2", default-features = false, features = ["tls"] } +# PNG encoder for the MSIX visual assets. Only pulled in by the `store` feature, +# which builds the `gen_msix_assets` helper bin; the GUI itself never links it. +ico = { version = "0.3", optional = true } [target.'cfg(windows)'.dependencies] # Initial dark-mode detection: read the Windows "apps use light theme" setting. @@ -48,7 +52,19 @@ spike = ["dep:tunnels", "dep:tokio", "dep:env_logger"] # `cargo build` stays light (no vendored OpenSSL / heavy toolchain). # env_logger is no longer needed here: the GUI installs its own capturing # logger (src/logbuf.rs) in every build. The spike bin still uses env_logger. -hosting = ["dep:tunnels", "dep:tokio", "dep:ureq"] +hosting = ["dep:tunnels", "dep:tokio"] +# Microsoft Store (MSIX) build. The MSIX container virtualizes the registry and +# filesystem and manages install/update itself, so the self-install relocation, +# the HKCU Run-key auto-start, and the GitHub-Releases update checker are all +# either broken or against Store policy inside the package. This feature compiles +# them out: install/update are handled by the MSIX package, and auto-start is +# declared via the `windows.startupTask` manifest extension (user-managed in +# Windows Settings > Startup apps). See docs/store/README.md. +# +# `store` always pulls in `hosting`: the Host button is core to the product, so a +# Store build without it makes no sense. Building it needs NASM + Strawberry Perl +# on PATH (vendored OpenSSL) — see CLAUDE.md. +store = ["dep:ico", "hosting"] [[bin]] name = "devtunnel_gui" @@ -59,6 +75,18 @@ name = "host_spike" path = "src/bin/host_spike.rs" required-features = ["spike"] +[[bin]] +name = "two_host_probe" +path = "src/bin/two_host_probe.rs" +required-features = ["spike"] + +# Renders the MSIX visual assets (tile/logo PNGs) from the procedural app icon so +# the Store package's Assets\ folder is reproducible. Run via packaging/msix/build-msix.ps1. +[[bin]] +name = "gen_msix_assets" +path = "src/bin/gen_msix_assets.rs" +required-features = ["store"] + [build-dependencies] slint-build = "1.13" diff --git a/docs/store/README.md b/docs/store/README.md new file mode 100644 index 0000000..a20b164 --- /dev/null +++ b/docs/store/README.md @@ -0,0 +1,127 @@ +# Publishing to the Microsoft Store + +End-to-end runbook for shipping **TunnelDeck for Dev Tunnels** to the Microsoft +Store as an MSIX package. Work top to bottom; each step links to the artifact that +implements it. + +| Artifact | Purpose | +|---|---| +| `store` cargo feature | Compiles out self-install, the GitHub update checker, and the HKCU auto-start (all MSIX-incompatible / against policy). Auto-start moves to the manifest. | +| `packaging/msix/AppxManifest.xml` | Package manifest: identity placeholders, full-trust app, `windows.startupTask`. | +| `packaging/msix/build-msix.ps1` | Builds the exe, renders assets, packs the `.msix`, optional sign + WACK. | +| `src/bin/gen_msix_assets.rs` | Renders the tile/logo PNGs from the app icon (run by the script). | +| `docs/store/listing.md` | Store listing copy: name, description, features, keywords, screenshots, age rating. | +| `docs/store/privacy-policy.md` | Privacy policy to publish and link (required). | + +--- + +## Step 1 — Partner Center account & app name + +1. Create a **Microsoft Partner Center** developer account (one-time fee: ~US$19 + individual / US$99 company): https://partner.microsoft.com/dashboard/registration +2. **Apps and games → New product → MSIX or PWA app.** +3. **Reserve the name** `TunnelDeck for Dev Tunnels`. + - The ` for Dev Tunnels` form is used deliberately: it avoids a + trademark rejection for leading with Microsoft's product name. Do **not** + reserve just "Dev Tunnels …". +4. Open **Product → Product identity** and copy these three values — you'll pass + them to `build-msix.ps1`: + - **Package/Identity/Name** → `-IdentityName` + - **Package/Identity/Publisher** (`CN=…`) → `-PublisherId` + - **Publisher display name** → `-PublisherDisplayName` + +## Step 2 — Build the store executable + +The `store` feature strips the MSIX-incompatible bits and **pulls in `hosting`** +(the Host button is core to the product). That builds the `tunnels` SDK + vendored +OpenSSL, which needs **NASM** and **Strawberry Perl** on `PATH` (see the repo +`CLAUDE.md`). On this machine, prepend before building: + +```powershell +$env:PATH = "C:\Strawberry\perl\bin;C:\Strawberry\c\bin;C:\Users\PICHAU\AppData\Local\bin\NASM;$env:PATH" +cargo build --release --features store --bin devtunnel_gui +``` + +`build-msix.ps1` runs this for you. + +## Step 3 — Fill in the manifest identity & package + +Put the three Partner Center identity values into a `.env` file (gitignored), then +run the script with no arguments. `build-msix.ps1` builds the exe, renders +`Assets\`, substitutes the identity into the manifest, and packs the `.msix`. + +```powershell +cd packaging\msix +Copy-Item .env.example .env +notepad .env # fill IDENTITY_NAME, PUBLISHER_ID, PUBLISHER_DISPLAY_NAME +.\build-msix.ps1 +``` + +(You can still override any value on the command line, e.g. `-Version 0.2.0.0`.) + +Output: `packaging\msix\out\TunnelDeck-0.1.0.0.msix` (**unsigned** — correct for +submission; the Store re-signs it). + +## Step 4 — Test locally + certify (WACK) + +The submission package is unsigned, but to **install and test locally** you need a +self-signed cert whose subject exactly equals `Identity/@Publisher`: + +```powershell +# One-time: create a test cert (subject must match your -PublisherId) +$cert = New-SelfSignedCertificate -Type Custom -Subject "CN=Paulo Corcino" ` + -KeyUsage DigitalSignature -CertStoreLocation "Cert:\CurrentUser\My" ` + -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") +Export-PfxCertificate -Cert $cert -FilePath .\TunnelDeck-test.pfx ` + -Password (ConvertTo-SecureString -String "test" -Force -AsPlainText) + +# Build a signed test package (identity comes from .env): +.\build-msix.ps1 -Sign + +# Install it (self-signed → first trust the cert; needs an ELEVATED prompt): +Import-PfxCertificate -FilePath .\TunnelDeck-test.pfx ` + -CertStoreLocation Cert:\LocalMachine\TrustedPeople ` + -Password (ConvertTo-SecureString "test" -Force -AsPlainText) +Add-AppxPackage .\out\TunnelDeck-0.1.0.0.msix + +# Run the certification kit — WACK requires an ELEVATED (Administrator) prompt: +.\build-msix.ps1 -Sign -Wack +``` + +Fix any **WACK** failures before submitting. Then rebuild **without** `-Sign` to +produce the clean unsigned package for upload. + +Smoke-test the installed app: +- Launches to the tray; window opens; tunnels list loads. +- Settings → General shows **no** "Start with Windows" toggle (managed by the + package). Settings → Status shows **no** install/uninstall rows. +- No "update available" banner appears (checker compiled out). +- Enable auto-start via **Windows Settings → Apps → Startup** and confirm it + launches at logon. + +## Step 5 — Create the submission + +In Partner Center, on the reserved product: + +1. **Packages** — upload the unsigned `.msix`. Set device family to **Desktop**. +2. **Store listing** — paste everything from [`listing.md`](listing.md): + name, short + full description, features, search terms, category + (Developer tools), copyright, support email, and screenshots (≥ 1, 1366×768+). +3. **Privacy policy URL** — `https://paulocorcino.github.io/devtunnel_gui/`. + The `Deploy Pages` workflow publishes [`site/index.html`](site/index.html) + (mirror of [`privacy-policy.md`](privacy-policy.md)). One-time: repo + **Settings → Pages → Source = GitHub Actions**. Required field. +4. **Age ratings** — complete the IARC questionnaire (see `listing.md`; expected + result: Everyone / PEGI 3). +5. **Pricing and availability** — Free; pick markets. +6. **Submit for certification.** Microsoft's automated + manual review typically + takes hours to a couple of days. If rejected, the report says why — the most + likely notes here are name/trademark or the CLI dependency; address and + resubmit. + +## Recurring: shipping an update + +1. Bump the version (e.g. `-Version 0.2.0.0`; the 4th part must stay `0`). +2. Re-run `build-msix.ps1`, re-test, upload the new unsigned `.msix`. +3. Update **What's new** and submit. The Store delivers the update to users; the + in-app updater stays disabled in this build by design. diff --git a/docs/store/listing.md b/docs/store/listing.md new file mode 100644 index 0000000..e86dad8 --- /dev/null +++ b/docs/store/listing.md @@ -0,0 +1,176 @@ +# Microsoft Store listing — TunnelDeck for Dev Tunnels + +Copy-paste source for the Partner Center **Store listing** page. All text is in +English (project rule). Character limits are Microsoft's current maximums. + +--- + +## App name (reserved in Partner Center) + +``` +TunnelDeck for Dev Tunnels +``` + +> Uses the ` for Dev Tunnels` pattern so the Store review accepts it: it +> names your independent product first and references the Microsoft service it +> builds on, without implying Microsoft authorship. Keep the in-app "About" +> disclaimer ("Not affiliated with or endorsed by Microsoft"). + +## Short description / subtitle (≤ 100 chars) + +``` +Turn localhost into a secure public HTTPS URL in one click — right from your Windows tray. +``` + +## Description (≤ 10,000 chars) + +``` +Share what you're building — instantly. + +TunnelDeck puts a public, secure HTTPS URL in front of any service running on +your machine, in a single click. Start your local app, pick the port, and hand +a working link to a teammate, a client, or a webhook — no firewall rules, no +router setup, no config files. + +It lives quietly in your Windows tray and stays out of your way until you need +it. When you do, it's a click: create a tunnel, copy the link, and go. + +WHY YOU'LL LIKE IT + +• One click to public — expose a local port as a live HTTPS URL and copy it to + your clipboard, ready to paste anywhere. +• Built for demos and testing — show work-in-progress to anyone, anywhere, + without deploying first. +• Test webhooks the easy way — give Stripe, GitHub, Twilio, or any provider a + reachable endpoint that points straight at your dev machine. +• Cross-device previews — open your site on a phone, tablet, or a colleague's + laptop from the same secure link. +• Keep it alive — TunnelDeck keeps your tunnel running and reconnects for you, + so the link keeps working while you work. +• Private by default — tunnels are authenticated unless you choose to make them + public, so only the people you want can reach your machine. +• Stays tidy — a clean tray app with a focused window. No dashboards to learn, + no clutter. + +HOW IT WORKS + +TunnelDeck is a friendly desktop front end for Microsoft Dev Tunnels — the same +free, security-focused tunneling service used across Visual Studio and VS Code. +Your traffic runs over Microsoft's infrastructure; TunnelDeck just makes it +effortless to create, name, share, and keep tunnels alive from Windows. + +You sign in with your own Microsoft, Entra ID, or GitHub account — the identity +Dev Tunnels already uses — and your tunnels are yours. + +GOOD TO KNOW + +• Requires the free Microsoft Dev Tunnels CLI (devtunnel). If it isn't already + on your machine, TunnelDeck points you to the one-line install. +• No inbound ports are opened on your machine. Traffic flows outbound over + HTTPS only. +• Windows tray app. The Dev Tunnels service itself is free. + +TunnelDeck is an independent client built on top of the official Microsoft Dev +Tunnels service. It is not affiliated with, sponsored by, or endorsed by +Microsoft. +``` + +## Product features (Partner Center "Features", ≤ 20 items, ≤ 200 chars each) + +``` +One click from localhost to a secure public HTTPS URL +Copy-ready links for demos, client previews, and cross-device testing +Point webhooks (Stripe, GitHub, Twilio, …) straight at your dev machine +Keeps tunnels alive and reconnects automatically +Authenticated by default — you decide what's public +Lightweight Windows tray app, no dashboard to learn +Sign in with your own Microsoft, Entra ID, or GitHub account +Outbound HTTPS only — no inbound ports opened +``` + +## Search terms (Partner Center, ≤ 7 terms, ≤ 30 chars each — not shown to users) + +``` +tunnel +localhost +dev tunnel +public url +webhook testing +share localhost +reverse proxy +``` + +## Category + +``` +Developer tools +``` + +(Sub-category: Development kits, or Utilities & tools.) + +## Copyright / additional info + +- **Copyright:** `© 2026 Paulo Corcino` +- **Website:** your GitHub repo or project page (e.g. https://github.com/paulocorcino/devtunnel_gui) +- **Support contact:** paulo@corcino.com.br +- **Privacy policy URL:** `https://paulocorcino.github.io/devtunnel_gui/` — served + from `docs/store/site/index.html` by the `Deploy Pages` workflow (enable Pages = + GitHub Actions once). Required field. + +## What's new in this version (release notes) + +``` +First Microsoft Store release of TunnelDeck for Dev Tunnels. Create, share, and +keep Microsoft Dev Tunnels alive from your Windows tray. +``` + +--- + +## Screenshots (required: at least 1; recommended 3–5) + +Store requirements for desktop: PNG, **1366 × 768** or larger, 16:9 preferred. +Capture from the running app (light and/or dark theme): + +1. Main window with a couple of tunnels, one showing a live public URL. +2. Creating a tunnel / adding a port. +3. The tray icon + menu. +4. Settings (General) — probe interval, default expiration, log level. +5. About panel (shows the Microsoft attribution + disclaimer). + +Use the helper (from your signed-in session, with the window open — the app +starts in the tray, so click the tray icon first): + +```powershell +packaging\msix\capture-screenshots.ps1 -Name 01-main +packaging\msix\capture-screenshots.ps1 -Name 02-create +packaging\msix\capture-screenshots.ps1 -Name 03-settings +``` + +It composes the window, centred, on a 1920×1080 indigo canvas and writes to +`docs/store/screenshots/`. Add a one-line caption per screenshot in Partner Center. + +## Age rating (IARC questionnaire) + +TunnelDeck is a developer utility with no in-app content, ads, purchases, or +user-generated content that the app itself hosts. Expected answers: + +- Contains violence / sexual / profanity / controlled substances: **No** to all. +- Users can interact / share content / exchange location or personal info: **No** + (the app creates network tunnels for the user's own services; it is not a + social or communication platform). +- Collects/shares personal data for advertising: **No**. + +Expected outcome: **Everyone / PEGI 3 / ESRB Everyone**. Answer the questionnaire +truthfully in Partner Center; IARC assigns the rating automatically. + +## Store submission checklist + +- [ ] App name reserved (`TunnelDeck for Dev Tunnels`). +- [ ] Package identity values copied into the manifest via build-msix.ps1. +- [ ] **Unsigned** .msix uploaded (Store re-signs; a signed package is rejected). +- [ ] WACK passed locally. +- [ ] Description, features, search terms, category filled in. +- [ ] ≥ 1 screenshot (1366×768+). +- [ ] Privacy policy URL live and reachable. +- [ ] Age rating questionnaire completed. +- [ ] Support email + copyright set. diff --git a/docs/store/privacy-policy.md b/docs/store/privacy-policy.md new file mode 100644 index 0000000..7289d6b --- /dev/null +++ b/docs/store/privacy-policy.md @@ -0,0 +1,62 @@ +# Privacy Policy — TunnelDeck for Dev Tunnels + +_Last updated: 2026-07-06_ + +TunnelDeck for Dev Tunnels ("TunnelDeck", "the app") is a Windows desktop client +for Microsoft Dev Tunnels, published by Paulo Corcino ("we", "us"). This policy +explains what the app does and does not do with your data. + +## Summary + +**We do not collect, store, or transmit any personal data to us.** TunnelDeck has +no analytics, no advertising, and no developer-operated servers. It runs entirely +on your machine and talks only to Microsoft's services on your behalf. + +## What the app does + +- **Sign-in and tunnels.** TunnelDeck uses the official Microsoft Dev Tunnels CLI + and SDK to sign you in and to create, list, host, and delete tunnels. You + authenticate directly with Microsoft (Microsoft account, Microsoft Entra ID, or + GitHub). Your credentials and tokens are handled by Microsoft's tooling and are + never sent to us. +- **Local settings.** Your preferences (such as default expiration, log level, + and probe interval) are stored locally on your computer. They never leave your + machine. +- **Network traffic.** When you host a tunnel, traffic between the public URL and + your local service transits Microsoft's Dev Tunnels infrastructure, subject to + Microsoft's own terms and privacy practices. TunnelDeck does not intercept, + log, or forward that traffic to us. + +## Data we collect + +None. TunnelDeck contains no telemetry, crash reporting, analytics, or +advertising SDKs. We operate no servers that receive data from the app. + +## Third-party services + +- **Microsoft Dev Tunnels** — the tunneling service the app is built on. Your use + of it is governed by Microsoft's terms and privacy statement: + https://learn.microsoft.com/azure/developer/dev-tunnels/security +- **Microsoft Store** — handles app distribution and updates, and may collect + usage and diagnostic data under Microsoft's privacy statement, independently of + this app. + +## Your responsibility + +Tunnels you create expose a service running on your computer to the internet. +What that service returns is under your control. Keep tunnels private and +short-lived unless you intend them to be public. + +## Children's privacy + +TunnelDeck is a developer tool and is not directed at children. It collects no +personal information from anyone. + +## Changes + +We may update this policy; the "Last updated" date above reflects the current +version. + +## Contact + +Questions about this policy: paulo@corcino.com.br diff --git a/docs/store/screenshots/.gitignore b/docs/store/screenshots/.gitignore new file mode 100644 index 0000000..84ba93e --- /dev/null +++ b/docs/store/screenshots/.gitignore @@ -0,0 +1,2 @@ +# Generated locally per session; not versioned. +*.png diff --git a/docs/store/site/index.html b/docs/store/site/index.html new file mode 100644 index 0000000..c4a26ba --- /dev/null +++ b/docs/store/site/index.html @@ -0,0 +1,112 @@ + + + + + + Privacy Policy — TunnelDeck for Dev Tunnels + + + + +
+
+

Privacy Policy

+
TunnelDeck for Dev Tunnels · Last updated: 2026-07-06
+
+ +

TunnelDeck for Dev Tunnels (“TunnelDeck”, “the app”) + is a Windows desktop client for Microsoft Dev Tunnels, published by + Paulo Corcino (“we”, “us”). This policy explains what + the app does and does not do with your data.

+ +
+ Summary. We do not collect, store, or transmit any personal + data to us. TunnelDeck has no analytics, no advertising, and no + developer-operated servers. It runs entirely on your machine and talks only + to Microsoft’s services on your behalf. +
+ +

What the app does

+
    +
  • Sign-in and tunnels. TunnelDeck uses the official + Microsoft Dev Tunnels CLI and SDK to sign you in and to create, list, host, + and delete tunnels. You authenticate directly with Microsoft (Microsoft + account, Microsoft Entra ID, or GitHub). Your credentials and tokens are + handled by Microsoft’s tooling and are never sent to us.
  • +
  • Local settings. Your preferences (such as default + expiration, log level, and probe interval) are stored locally on your + computer. They never leave your machine.
  • +
  • Network traffic. When you host a tunnel, traffic between + the public URL and your local service transits Microsoft’s Dev Tunnels + infrastructure, subject to Microsoft’s own terms and privacy practices. + TunnelDeck does not intercept, log, or forward that traffic to us.
  • +
+ +

Data we collect

+

None. TunnelDeck contains no telemetry, crash reporting, analytics, or + advertising SDKs. We operate no servers that receive data from the app.

+ +

Third-party services

+
    +
  • Microsoft Dev Tunnels — the tunneling service the app is + built on. Your use of it is governed by Microsoft’s terms and privacy + statement: + learn.microsoft.com/azure/developer/dev-tunnels/security.
  • +
  • Microsoft Store — handles app distribution and updates, + and may collect usage and diagnostic data under Microsoft’s privacy + statement, independently of this app.
  • +
+ +

Your responsibility

+

Tunnels you create expose a service running on your computer to the + internet. What that service returns is under your control. Keep tunnels private + and short-lived unless you intend them to be public.

+ +

Children’s privacy

+

TunnelDeck is a developer tool and is not directed at children. It collects + no personal information from anyone.

+ +

Changes

+

We may update this policy; the “Last updated” date above reflects + the current version.

+ +

Contact

+

Questions about this policy: paulo@corcino.com.br

+ +
+ TunnelDeck is an independent client built on top of the official Microsoft + Dev Tunnels service. It is not affiliated with, sponsored by, or endorsed by + Microsoft. +
+
+ + diff --git a/i18n/en-US/app.ftl b/i18n/en-US/app.ftl index dcf6361..5c82516 100644 --- a/i18n/en-US/app.ftl +++ b/i18n/en-US/app.ftl @@ -119,7 +119,7 @@ confirm-uninstall = Uninstall DevTunnel GUI? This removes the Start-menu shortcu ## About about-title = About -about-app-name = Dev Tunnels GUI +about-app-name = TunnelDeck for Dev Tunnels about-version-label = Version about-tagline = Manage Microsoft Dev Tunnels from your Windows tray. about-built-on = Built on Microsoft Dev Tunnels — Microsoft's free, security-focused tunneling service — and its official CLI and SDK. Not affiliated with or endorsed by Microsoft. @@ -138,6 +138,12 @@ relogin-message = Sign-in expired — sign in again to keep hosting btn-sign-in = Sign in banner-action-open-settings = Open Settings +## Update available banner +update-banner-title = Update available +update-banner-body = Version { $version } is available — you're on an older build. +btn-update-download = View release +btn-update-ignore = Ignore + ## Install CLI progress / outcome install-status-running = Installing… install-status-done = Dev Tunnels CLI installed @@ -166,7 +172,7 @@ badge-stopped = Stopped badge-hosting = Hosting… ## Top bar (redesign) -app-title = Dev Tunnels +app-title = TunnelDeck pill-connected = Connected tooltip-settings = Toggle dark mode diff --git a/packaging/msix/.env.example b/packaging/msix/.env.example new file mode 100644 index 0000000..99a99fd --- /dev/null +++ b/packaging/msix/.env.example @@ -0,0 +1,26 @@ +# TunnelDeck — Microsoft Store package identity. +# +# Copy this file to `.env` in the same folder and fill in the values from +# Partner Center (Product > Product identity). Then just run: +# +# .\build-msix.ps1 +# +# `.env` is gitignored — your identity values are never committed. + +# Package/Identity/Name (e.g. 12345Publisher.TunnelDeckforDevTunnels) +IDENTITY_NAME= + +# Package/Identity/Publisher (the full CN=... string, e.g. CN=ABCDEF01-2345-6789-ABCD-EF0123456789) +PUBLISHER_ID= + +# Publisher display name (e.g. Paulo Corcino) +PUBLISHER_DISPLAY_NAME= + +# 4-part version; the 4th part MUST be 0 for the Store. Defaults to 0.1.0.0. +VERSION=0.1.0.0 + +# --- Local sideload testing only (optional) ---------------------------------- +# Path to a self-signed .pfx whose subject equals PUBLISHER_ID above, used with +# -Sign. Leave blank for the (unsigned) submission package. See docs/store/README.md. +CERT_PATH= +CERT_PASSWORD= diff --git a/packaging/msix/.gitignore b/packaging/msix/.gitignore new file mode 100644 index 0000000..17fed1b --- /dev/null +++ b/packaging/msix/.gitignore @@ -0,0 +1,8 @@ +# Generated by build-msix.ps1 / gen_msix_assets — reproducible from source. +/layout/ +/out/ +/Assets/ + +# Local identity + test certs — never commit. +.env +*.pfx diff --git a/packaging/msix/AppxManifest.xml b/packaging/msix/AppxManifest.xml new file mode 100644 index 0000000..a10377c --- /dev/null +++ b/packaging/msix/AppxManifest.xml @@ -0,0 +1,82 @@ + + + + + + + + TunnelDeck for Dev Tunnels + __PUBLISHER_DISPLAY_NAME__ + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/msix/build-msix.ps1 b/packaging/msix/build-msix.ps1 new file mode 100644 index 0000000..2575dfd --- /dev/null +++ b/packaging/msix/build-msix.ps1 @@ -0,0 +1,212 @@ +<# +.SYNOPSIS + Builds the Microsoft Store (MSIX) package for TunnelDeck for Dev Tunnels. + +.DESCRIPTION + 1. Compiles the release executable with the `store` cargo feature (self-install, + update-checker and HKCU auto-start compiled out - the package owns those). + 2. Renders the visual assets from the procedural app icon (gen_msix_assets). + 3. Assembles a package layout, substituting the Partner Center identity values + into AppxManifest.xml. + 4. Packs it into an .msix with makeappx.exe from the Windows SDK. + 5. Optionally signs it with a local test certificate for sideload testing, and/or + runs the Windows App Certification Kit (WACK). + + The .msix you upload to Partner Center must be UNSIGNED (the Store re-signs it) - + so only pass -Sign when you want to install/test locally, and produce a separate + unsigned package for submission. + +.PARAMETER IdentityName + Package/Identity/Name from Partner Center (Product identity page). + +.PARAMETER PublisherId + Package/Identity/Publisher from Partner Center, e.g. "CN=1234ABCD-...". + +.PARAMETER PublisherDisplayName + The publisher display name from Partner Center. + +.PARAMETER Version + 4-part version a.b.c.0 (the 4th part must be 0 for the Store). Default 0.1.0.0. + +.PARAMETER Sign + Sign the package with -CertPath for local sideload testing. Do NOT submit a signed + package to the Store. + +.PARAMETER Wack + Run the Windows App Certification Kit against the built package after packing. + +.EXAMPLE + # Submission package (unsigned): + .\build-msix.ps1 -IdentityName 12345Publisher.TunnelDeck ` + -PublisherId "CN=ABCDEF01-2345-6789-ABCD-EF0123456789" ` + -PublisherDisplayName "Paulo Corcino" -Version 0.1.0.0 + +.EXAMPLE + # Local test package, self-signed and validated: + .\build-msix.ps1 -IdentityName 12345Publisher.TunnelDeck ` + -PublisherId "CN=Paulo Corcino" -PublisherDisplayName "Paulo Corcino" ` + -Sign -CertPath .\TunnelDeck-test.pfx -Wack +#> +[CmdletBinding()] +param( + [string] $IdentityName, + [string] $PublisherId, + [string] $PublisherDisplayName, + [string] $Version, + [switch] $Sign, + [string] $CertPath, + [string] $CertPassword, + [switch] $Wack, + # .env file with the Partner Center identity values. Any parameter you pass + # explicitly wins over the file. + [string] $EnvFile +) + +$ErrorActionPreference = "Stop" +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..") +$layout = Join-Path $scriptDir "layout" +$outDir = Join-Path $scriptDir "out" + +# --- Load identity from .env (parameters passed explicitly take precedence) ---- +# Fill in packaging\msix\.env (copy from .env.example) so you can just run +# `.\build-msix.ps1` with no arguments. +if (-not $EnvFile) { $EnvFile = Join-Path $scriptDir ".env" } +$envMap = @{} +if (Test-Path $EnvFile) { + Write-Host "Loading identity from $EnvFile" + foreach ($line in Get-Content $EnvFile) { + $t = $line.Trim() + if ($t -eq "" -or $t.StartsWith("#")) { continue } + $kv = $t -split '=', 2 + if ($kv.Count -eq 2) { $envMap[$kv[0].Trim()] = $kv[1].Trim().Trim('"') } + } +} +if (-not $IdentityName) { $IdentityName = $envMap["IDENTITY_NAME"] } +if (-not $PublisherId) { $PublisherId = $envMap["PUBLISHER_ID"] } +if (-not $PublisherDisplayName) { $PublisherDisplayName = $envMap["PUBLISHER_DISPLAY_NAME"] } +if (-not $Version) { $Version = $envMap["VERSION"] } +if (-not $CertPath) { $CertPath = $envMap["CERT_PATH"] } +if (-not $CertPassword) { $CertPassword = $envMap["CERT_PASSWORD"] } +if (-not $Version) { $Version = "0.1.0.0" } + +# A relative CERT_PATH is resolved against the repo root, so the .env value works +# no matter which directory you run the script from. +if ($CertPath -and -not [System.IO.Path]::IsPathRooted($CertPath)) { + $CertPath = Join-Path $repoRoot $CertPath +} + +$missing = @() +if (-not $IdentityName) { $missing += "IdentityName / IDENTITY_NAME" } +if (-not $PublisherId) { $missing += "PublisherId / PUBLISHER_ID" } +if (-not $PublisherDisplayName) { $missing += "PublisherDisplayName / PUBLISHER_DISPLAY_NAME" } +if ($missing.Count -gt 0) { + throw "Missing identity value(s): $($missing -join ', '). Set them in $EnvFile (copy .env.example) or pass as parameters. Get them from Partner Center > Product identity." +} + +$msixPath = Join-Path $outDir "TunnelDeck-$Version.msix" + +if ($Version -notmatch '^\d+\.\d+\.\d+\.0$') { + throw "Version must be a.b.c.0 (the 4th part must be 0 for the Store); got '$Version'." +} + +# --- Locate the latest Windows SDK bin (makeappx, signtool, appcert) ---------- +function Find-SdkTool([string]$name) { + $roots = @("${env:ProgramFiles(x86)}\Windows Kits\10\bin", "${env:ProgramFiles}\Windows Kits\10\bin") + $found = foreach ($root in $roots) { + if (Test-Path $root) { + Get-ChildItem -Path $root -Recurse -Filter $name -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\' } + } + } + $tool = $found | Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $tool) { throw "$name not found. Install the Windows 10/11 SDK." } + return $tool.FullName +} + +$makeappx = Find-SdkTool "makeappx.exe" +Write-Host "makeappx: $makeappx" + +# --- 1. Build the store executable ------------------------------------------- +# The `store` feature pulls in `hosting` (Host button) - needs NASM + Strawberry +# Perl on PATH for the vendored-OpenSSL build (see docs/store/README.md). +Write-Host "`n[1/4] Building release executable (--features store)..." +Push-Location $repoRoot +try { + & cargo build --release --features store --bin devtunnel_gui + if ($LASTEXITCODE -ne 0) { throw "cargo build failed." } + + # --- 2. Render visual assets --------------------------------------------- + Write-Host "`n[2/4] Rendering MSIX assets..." + & cargo run --release --features store --bin gen_msix_assets -- (Join-Path $scriptDir "Assets") + if ($LASTEXITCODE -ne 0) { throw "asset generation failed." } +} +finally { Pop-Location } + +$exe = Join-Path $repoRoot "target\release\devtunnel_gui.exe" +if (-not (Test-Path $exe)) { throw "Built executable not found at $exe" } + +# --- 3. Assemble the package layout ------------------------------------------ +Write-Host "`n[3/4] Assembling package layout..." +if (Test-Path $layout) { Remove-Item $layout -Recurse -Force } +New-Item -ItemType Directory -Path $layout | Out-Null +New-Item -ItemType Directory -Path $outDir -Force | Out-Null + +Copy-Item $exe (Join-Path $layout "devtunnel_gui.exe") +Copy-Item (Join-Path $scriptDir "Assets") (Join-Path $layout "Assets") -Recurse + +# Substitute the Partner Center identity values into the manifest. +$manifest = Get-Content (Join-Path $scriptDir "AppxManifest.xml") -Raw +$manifest = $manifest.Replace("__IDENTITY_NAME__", $IdentityName) +$manifest = $manifest.Replace("__PUBLISHER_ID__", $PublisherId) +$manifest = $manifest.Replace("__PUBLISHER_DISPLAY_NAME__", $PublisherDisplayName) +# Case-sensitive so it targets Identity's Version="..." and NOT the lowercase +# version="1.0" in the declaration. +$manifest = $manifest -creplace 'Version="[\d.]+"', "Version=`"$Version`"" +Set-Content -Path (Join-Path $layout "AppxManifest.xml") -Value $manifest -Encoding UTF8 + +# --- 4. Pack ----------------------------------------------------------------- +Write-Host "`n[4/4] Packing $msixPath ..." +if (Test-Path $msixPath) { Remove-Item $msixPath -Force } +& $makeappx pack /d $layout /p $msixPath /o +if ($LASTEXITCODE -ne 0) { throw "makeappx pack failed." } +Write-Host "Package built: $msixPath" + +# --- Optional: sign for local sideload testing ------------------------------- +if ($Sign) { + if (-not $CertPath) { throw "-Sign requires -CertPath ." } + $signtool = Find-SdkTool "signtool.exe" + Write-Host "`nSigning (local test only - do NOT submit a signed package)..." + $args = @("sign", "/fd", "SHA256", "/a", "/f", $CertPath) + if ($CertPassword) { $args += @("/p", $CertPassword) } + $args += $msixPath + & $signtool @args + if ($LASTEXITCODE -ne 0) { throw "signtool failed. The cert's subject must equal Identity/@Publisher ($PublisherId)." } + Write-Host "Signed. Install locally with: Add-AppxPackage '$msixPath'" +} + +# --- Optional: Windows App Certification Kit --------------------------------- +if ($Wack) { + # appcert.exe lives in the "App Certification Kit" folder, not bin\. + $appcert = $null + foreach ($base in @("${env:ProgramFiles(x86)}\Windows Kits\10", "${env:ProgramFiles}\Windows Kits\10")) { + $c = Join-Path $base "App Certification Kit\appcert.exe" + if (Test-Path $c) { $appcert = $c; break } + } + if (-not $appcert) { throw "appcert.exe not found. Install the Windows App Certification Kit (part of the Windows SDK)." } + + # WACK requires elevation. + $elevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if (-not $elevated) { throw "WACK (-Wack) requires an elevated (Administrator) PowerShell. Re-run this script from an admin prompt." } + + $report = Join-Path $outDir "wack-report.xml" + Write-Host "`nRunning Windows App Certification Kit (may take several minutes)..." + & $appcert reset + & $appcert test -appxpackagepath $msixPath -reportoutputpath $report + Write-Host "WACK report: $report" +} + +Write-Host "`nDone." +if (-not $Sign) { + Write-Host "Upload $msixPath to Partner Center (it must stay UNSIGNED for submission)." +} diff --git a/packaging/msix/capture-screenshots.ps1 b/packaging/msix/capture-screenshots.ps1 new file mode 100644 index 0000000..9c0313a --- /dev/null +++ b/packaging/msix/capture-screenshots.ps1 @@ -0,0 +1,178 @@ +<# +.SYNOPSIS + Captures the app window for Microsoft Store screenshots. + +.DESCRIPTION + Grabs the TunnelDeck window and composes it, centred, on a 16:9 canvas with a + soft indigo backdrop - the format the Store expects (min 1366x768; this defaults + to 1920x1080). Saves both the raw window PNG and the composed canvas PNG to + docs/store/screenshots/. + + Run this from your signed-in session, once per state you want to show (main + list, creating a tunnel, settings, about), passing a distinct -Name each time: + + .\capture-screenshots.ps1 -Name 01-main + .\capture-screenshots.ps1 -Name 02-settings + + Use -Launch to start the app first (otherwise it captures the already-running + instance). The window must be visible and not minimized to the tray. + +.PARAMETER Name + Base filename (no extension) for this capture. + +.PARAMETER Launch + Start the executable and wait for its window before capturing. + +.PARAMETER Exe + Path to the executable. Defaults to target\release\devtunnel_gui.exe. + +.PARAMETER CanvasWidth / CanvasHeight + Composed canvas size. Default 1920x1080 (16:9). Store minimum is 1366x768. +#> +[CmdletBinding()] +param( + [string] $Name = "01-main", + [switch] $Launch, + [string] $Exe, + [int] $CanvasWidth = 1920, + [int] $CanvasHeight = 1080 +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Drawing + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..") +if (-not $Exe) { $Exe = Join-Path $repoRoot "target\release\devtunnel_gui.exe" } +$outDir = Join-Path $repoRoot "docs\store\screenshots" +New-Item -ItemType Directory -Path $outDir -Force | Out-Null + +# --- Win32 interop ----------------------------------------------------------- +# The Slint UI window is a separate top-level window (class 'Window Class'); the +# process's MainWindowHandle points at a tiny 16x16 winit helper window, so we +# enumerate all top-level windows for the PID and pick the largest visible one. +if (-not ("WinCap" -as [type])) { + Add-Type @" +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +public struct RECT { public int Left, Top, Right, Bottom; } +public static class WinCap { + public delegate bool EnumProc(IntPtr h, IntPtr l); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr h, int attr, out RECT r, int size); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + // True visible bounds, excluding the invisible DWM resize border / drop shadow + // that GetWindowRect includes on Windows 10/11 (DWMWA_EXTENDED_FRAME_BOUNDS=9). + public static RECT VisibleRect(IntPtr h) { + RECT r; + if (DwmGetWindowAttribute(h, 9, out r, Marshal.SizeOf(typeof(RECT))) == 0) return r; + GetWindowRect(h, out r); return r; + } + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags); + [DllImport("shcore.dll")] public static extern int SetProcessDpiAwareness(int v); + public static List ForPid(uint target) { + var res = new List(); + EnumWindows((h,l)=>{ uint p; GetWindowThreadProcessId(h, out p); if(p==target) res.Add(h); return true; }, IntPtr.Zero); + return res; + } +} +"@ + # Capture in physical pixels so the window isn't scaled/blurred on high DPI. + try { [WinCap]::SetProcessDpiAwareness(2) | Out-Null } catch {} +} + +# --- Find (or launch) the app window ----------------------------------------- +function Find-AppWindow { + $proc = Get-Process -Name "devtunnel_gui" -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $proc) { return $null } + $best = [IntPtr]::Zero; $bestArea = 0; $bestRect = $null + foreach ($h in [WinCap]::ForPid([uint32]$proc.Id)) { + if (-not [WinCap]::IsWindowVisible($h)) { continue } + $r = [WinCap]::VisibleRect($h) + $w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top + $area = $w * $ht + if ($w -ge 300 -and $ht -ge 300 -and $area -gt $bestArea) { + $best = $h; $bestArea = $area; $bestRect = $r + } + } + if ($best -eq [IntPtr]::Zero) { return $null } + return @{ Hwnd = $best; Rect = $bestRect } +} + +if ($Launch) { + if (-not (Test-Path $Exe)) { throw "Executable not found: $Exe (build it first)." } + Start-Process $Exe | Out-Null +} + +$win = $null +for ($i = 0; $i -lt 30 -and -not $win; $i++) { + $win = Find-AppWindow + if (-not $win) { Start-Sleep -Milliseconds 500 } +} +if (-not $win) { + throw "No visible TunnelDeck window (>=300x300) found. Open the window from the tray icon, then re-run this script." +} + +$hwnd = $win.Hwnd +# Raise the window above everything else so the screen grab isn't of whatever is +# covering it. SetForegroundWindow alone is unreliable from a background process +# (foreground lock), so pin it TOPMOST, capture, then release. +$HWND_TOPMOST = [IntPtr](-1); $HWND_NOTOPMOST = [IntPtr](-2) +$SWP = 0x0001 -bor 0x0002 -bor 0x0040 # NOSIZE | NOMOVE | SHOWWINDOW +[WinCap]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE +[WinCap]::SetWindowPos($hwnd, $HWND_TOPMOST, 0, 0, 0, 0, $SWP) | Out-Null +[WinCap]::SetForegroundWindow($hwnd) | Out-Null +Start-Sleep -Milliseconds 700 + +$rect = [WinCap]::VisibleRect($hwnd) +$w = $rect.Right - $rect.Left +$h = $rect.Bottom - $rect.Top + +# --- Capture the window ------------------------------------------------------ +$shot = New-Object System.Drawing.Bitmap $w, $h +$g = [System.Drawing.Graphics]::FromImage($shot) +$g.CopyFromScreen($rect.Left, $rect.Top, 0, 0, (New-Object System.Drawing.Size $w, $h)) +$g.Dispose() + +# Release the topmost pin now that the grab is done. +[WinCap]::SetWindowPos($hwnd, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP) | Out-Null + +$rawPath = Join-Path $outDir "$Name-window.png" +$shot.Save($rawPath, [System.Drawing.Imaging.ImageFormat]::Png) + +# --- Compose onto a 16:9 canvas with an indigo gradient ---------------------- +$canvas = New-Object System.Drawing.Bitmap $CanvasWidth, $CanvasHeight +$cg = [System.Drawing.Graphics]::FromImage($canvas) +$cg.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias +$cg.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + +$rectF = New-Object System.Drawing.Rectangle 0, 0, $CanvasWidth, $CanvasHeight +$c1 = [System.Drawing.Color]::FromArgb(255, 108, 111, 245) # #6c6ff5 (icon top) +$c2 = [System.Drawing.Color]::FromArgb(255, 79, 70, 229) # #4f46e5 (icon bottom) +$brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush $rectF, $c1, $c2, 90.0 +$cg.FillRectangle($brush, $rectF) + +# Scale the window down if it exceeds ~78% of the canvas, keeping aspect. +$maxW = [int]($CanvasWidth * 0.78); $maxH = [int]($CanvasHeight * 0.82) +$scale = [Math]::Min([Math]::Min($maxW / $w, $maxH / $h), 1.0) +$dw = [int]($w * $scale); $dh = [int]($h * $scale) +$dx = [int](($CanvasWidth - $dw) / 2); $dy = [int](($CanvasHeight - $dh) / 2) + +# Soft shadow behind the window. +$shadow = New-Object System.Drawing.Drawing2D.GraphicsPath +$sr = New-Object System.Drawing.Rectangle ($dx + 8), ($dy + 14), $dw, $dh +$cg.FillRectangle((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(60, 0, 0, 0))), $sr) +$cg.DrawImage($shot, $dx, $dy, $dw, $dh) +$cg.Dispose() + +$outPath = Join-Path $outDir "$Name.png" +$canvas.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png) +$shot.Dispose(); $canvas.Dispose() + +Write-Host "Captured window: $rawPath (${w}x${h})" +Write-Host "Store screenshot: $outPath (${CanvasWidth}x${CanvasHeight})" diff --git a/src/bin/gen_msix_assets.rs b/src/bin/gen_msix_assets.rs new file mode 100644 index 0000000..b66a3dd --- /dev/null +++ b/src/bin/gen_msix_assets.rs @@ -0,0 +1,112 @@ +//! Generates the Microsoft Store (MSIX) visual assets from the procedural app +//! icon, so the package's `Assets\` folder is fully reproducible from source +//! (no binary blobs checked in). Reuses `icon_render.rs` — the same renderer that +//! drives the tray icon and the embedded executable icon — and encodes PNGs with +//! the `ico` crate's PNG writer. +//! +//! Usage: `cargo run --features store --bin gen_msix_assets -- ` +//! (defaults to `packaging/msix/Assets` when no argument is given). Invoked by +//! `packaging/msix/build-msix.ps1`. + +// Share the std-only procedural renderer with the crate (same include! the build +// script uses to encode the .ico). +include!("../icon_render.rs"); + +use std::path::{Path, PathBuf}; + +/// One MSIX asset: output filename and the square edge size to render at. +struct Square { + name: &'static str, + size: u32, +} + +/// Square logos the manifest references. Scale-100 baseline — enough for a valid +/// package and WACK pass; add scale-125/150/200/400 variants later for crisper +/// tiles on high-DPI displays (same names with a `.scale-200` infix). +const SQUARES: &[Square] = &[ + // App-list / taskbar / Start small icon. + Square { + name: "Square44x44Logo.png", + size: 44, + }, + // Small tile. + Square { + name: "Square71x71Logo.png", + size: 71, + }, + // Medium tile (required). + Square { + name: "Square150x150Logo.png", + size: 150, + }, + // Large tile. + Square { + name: "Square310x310Logo.png", + size: 310, + }, + // Store listing logo carried inside the package. + Square { + name: "StoreLogo.png", + size: 50, + }, +]; + +/// Wide tile (310x150): the square mark centred on a transparent canvas. +const WIDE: (&str, u32, u32) = ("Wide310x150Logo.png", 310, 150); + +fn main() { + let out = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("packaging/msix/Assets")); + + if let Err(e) = std::fs::create_dir_all(&out) { + eprintln!("error: cannot create {}: {e}", out.display()); + std::process::exit(1); + } + + for sq in SQUARES { + let data = rgba(sq.size, IconVariant::Normal); + if let Err(e) = write_png(&out.join(sq.name), sq.size, sq.size, data) { + eprintln!("error: writing {}: {e}", sq.name); + std::process::exit(1); + } + println!(" {} ({}x{})", sq.name, sq.size, sq.size); + } + + let (name, w, h) = WIDE; + let data = wide_canvas(w, h); + if let Err(e) = write_png(&out.join(name), w, h, data) { + eprintln!("error: writing {name}: {e}"); + std::process::exit(1); + } + println!(" {name} ({w}x{h})"); + + println!("MSIX assets written to {}", out.display()); +} + +/// Builds a `w`x`h` RGBA canvas (transparent) with the square icon centred, +/// sized to the shorter edge. Used for the non-square wide tile. +fn wide_canvas(w: u32, h: u32) -> Vec { + let edge = w.min(h); + let icon = rgba(edge, IconVariant::Normal); + let ox = (w - edge) / 2; + let oy = (h - edge) / 2; + + let mut out = vec![0u8; (w * h * 4) as usize]; + for y in 0..edge { + for x in 0..edge { + let src = ((y * edge + x) * 4) as usize; + let dst = (((y + oy) * w + (x + ox)) * 4) as usize; + out[dst..dst + 4].copy_from_slice(&icon[src..src + 4]); + } + } + out +} + +/// Encodes straight RGBA8 pixels as a PNG file via the `ico` crate's writer. +fn write_png(path: &Path, w: u32, h: u32, rgba: Vec) -> std::io::Result<()> { + let image = ico::IconImage::from_rgba_data(w, h, rgba); + let file = std::fs::File::create(path)?; + image.write_png(file) +} diff --git a/src/bin/two_host_probe.rs b/src/bin/two_host_probe.rs new file mode 100644 index 0000000..656e7a5 --- /dev/null +++ b/src/bin/two_host_probe.rs @@ -0,0 +1,427 @@ +//! Two-host probe (HITL, issue #46): determines how the Dev Tunnels relay reacts +//! to a **second** in-process host connection on a tunnel id that is already being +//! hosted. The answer (coexist / evict / reject) is the go/no-go gate for the +//! make-before-break re-mint described in #46 — it cannot be derived statically or +//! from the single-host E2E, so this throwaway binary measures it live. +//! +//! Flow: +//! 1. Mint host + manage:ports tokens (subprocess `devtunnel token`). +//! 2. Start a local HTTP server (something to forward) and bring up **host A**: +//! connect → add_port. Confirm A is serving. +//! 3. While A is still live, mint fresh tokens and bring up **host B** on the +//! same tunnel id: connect → add_port. +//! 4. Classify the service behavior from authoritative SDK signals: +//! - B `connect` errors → REJECT +//! - A's relay handle resolves after B joins → EVICT (new evicts old) +//! - B's relay handle resolves after connect → EVICT (old evicts new) / soft reject +//! - both handles stay live for the watch window → COEXIST +//! 5. A best-effort HTTP poller curls the public URL throughout and records any +//! serving gap (informational; requires anonymous access on the port). +//! +//! Usage: +//! cargo run --features spike --bin two_host_probe -- +//! +//! The port must already exist on the tunnel (add_port treats 409 as OK). For the +//! HTTP gap measurement, enable anonymous access first: +//! devtunnel access create -p --anonymous + +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tunnels::connections::RelayTunnelHost; +use tunnels::contracts::TunnelPort; +use tunnels::management::{ + new_tunnel_management, Authorization, TunnelLocator, TunnelManagementClient, +}; + +const DEVTUNNEL: &str = "devtunnel"; +const MARKER: &str = "DEVTUNNEL_PROBE_OK"; + +fn devtunnel_bin() -> String { + std::env::var("DEVTUNNEL_BIN").unwrap_or_else(|_| DEVTUNNEL.to_string()) +} + +/// Process creation flag that suppresses the console window Windows would +/// otherwise flash for each subprocess. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// Builds a `Command` with the console window suppressed on Windows. +fn command(program: &str) -> Command { + let mut cmd = Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} + +/// `devtunnel token --scopes -j` → token string. One scope per call: +/// repeating `--scopes` corrupts the first value. +fn mint_token(full_id: &str, scope: &str) -> anyhow::Result { + let out = command(&devtunnel_bin()) + .args(["token", full_id, "--scopes", scope, "-j"]) + .output()?; + if !out.status.success() { + anyhow::bail!( + "devtunnel token ({scope}) failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + let v: serde_json::Value = serde_json::from_slice(&out.stdout)?; + v.get("token") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow::anyhow!("'token' field missing from devtunnel token output")) +} + +/// Mints both tokens a host needs (relay `host` + `manage:ports` for add_port). +fn mint_pair(full_id: &str) -> anyhow::Result<(String, String)> { + Ok(( + mint_token(full_id, "host")?, + mint_token(full_id, "manage:ports")?, + )) +} + +/// Fetches the real Public URL (portUri) for the port via `devtunnel show -j`. +fn fetch_port_uri(full_id: &str, port: u16) -> Option { + let out = command(&devtunnel_bin()) + .args(["show", full_id, "-j"]) + .output() + .ok()?; + let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; + let ports = v.get("tunnel")?.get("ports")?.as_array()?; + for p in ports { + if p.get("portNumber").and_then(|n| n.as_u64()) == Some(port as u64) { + return p + .get("portUri") + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + } + } + None +} + +/// Builds a host bound to `full_id` and connects it, returning the live host and +/// its relay handle (the handle future resolves when the connection drops). The +/// host MUST stay bound by the caller — dropping it tears the connection down. +async fn bring_up( + full_id: &str, + port: u16, + host_token: &str, + manage_token: String, +) -> anyhow::Result<(RelayTunnelHost, tunnels::connections::RelayHandle)> { + let (id, cluster) = full_id + .rsplit_once('.') + .map(|(i, c)| (i.to_string(), c.to_string())) + .ok_or_else(|| anyhow::anyhow!("tunnel id has no cluster: {full_id}"))?; + + let mut builder = new_tunnel_management("devtunnel-gui-probe/0.1"); + builder.authorization(Authorization::Tunnel(manage_token)); + let mgmt: TunnelManagementClient = builder.into(); + let locator = TunnelLocator::ID { cluster, id }; + + let mut host = RelayTunnelHost::new(locator, mgmt); + let handle = host.connect(host_token).await?; + let tunnel_port = TunnelPort { + port_number: port, + protocol: Some("http".to_string()), + ..Default::default() + }; + host.add_port(&tunnel_port).await?; + Ok((host, handle)) +} + +/// Minimal local HTTP server tagging responses so the poller can tell which side +/// is actually serving. +async fn run_local_server(port: u16) -> anyhow::Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind(("127.0.0.1", port)).await?; + log::info!("local test server listening on 127.0.0.1:{port}"); + loop { + let (mut sock, _) = listener.accept().await?; + tokio::spawn(async move { + let mut buf = [0u8; 2048]; + let _ = sock.read(&mut buf).await; + let body = format!("{MARKER}\n"); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.flush().await; + }); + } +} + +/// Best-effort serving check: curl the public URL (skipping the anti-phishing +/// interstitial) and report whether our marker came back. Returns `None` when +/// curl itself could not run. +fn curl_serves(uri: &str) -> Option { + let out = command("curl") + .args([ + "-s", + "-m", + "3", + "-H", + "X-Tunnel-Skip-AntiPhishing-Page: true", + uri, + ]) + .output() + .ok()?; + Some(String::from_utf8_lossy(&out.stdout).contains(MARKER)) +} + +/// Polls the public URL until `stop` is set, printing only on serving-state +/// transitions (with elapsed-since-start timestamps) so any gap is visible. +async fn poll_serving(uri: String, start: Instant, stop: Arc) { + let mut last: Option = None; + let mut probed_at_all = false; + while !stop.load(Ordering::Relaxed) { + let serving = curl_serves(&uri); + match serving { + Some(s) => { + probed_at_all = true; + if last != Some(s) { + let t = start.elapsed().as_millis(); + println!( + " [poll +{t:>6}ms] serving = {}", + if s { "YES" } else { "no" } + ); + last = Some(s); + } + } + None => { + if !probed_at_all { + println!(" [poll] curl unavailable — skipping HTTP gap measurement"); + return; + } + } + } + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + + let mut args = std::env::args().skip(1); + let full_id = args + .next() + .ok_or_else(|| anyhow::anyhow!("usage: two_host_probe "))?; + let port: u16 = args.next().and_then(|s| s.parse().ok()).unwrap_or(3000); + + println!("== two-host relay probe (issue #46) =="); + println!("tunnel id : {full_id}"); + println!("port : {port}\n"); + + // Local server to forward. + tokio::spawn(async move { + if let Err(e) = run_local_server(port).await { + log::error!("local server failed: {e}"); + } + }); + tokio::time::sleep(Duration::from_millis(300)).await; + + let start = Instant::now(); + + // ---- Host A ------------------------------------------------------------- + println!("[A] minting tokens + connecting…"); + let (a_host_tok, a_manage_tok) = mint_pair(&full_id)?; + let (_host_a, handle_a) = bring_up(&full_id, port, &a_host_tok, a_manage_tok).await?; + println!("[A] connected + port forwarded ✓"); + + // Start the HTTP gap poller (informational). + let stop = Arc::new(AtomicBool::new(false)); + let poller = { + let uri = fetch_port_uri(&full_id, port); + match uri { + Some(u) => { + println!("[A] public URL: {u}"); + let stop = stop.clone(); + Some(tokio::spawn(poll_serving(u, start, stop))) + } + None => { + println!("[A] could not resolve public URL — HTTP gap measurement skipped"); + None + } + } + }; + + // Let A settle and confirm it is serving before the second host joins. + println!("[A] holding 4s to confirm steady-state serving…"); + tokio::time::sleep(Duration::from_secs(4)).await; + + tokio::pin!(handle_a); + // Sanity: A must still be connected at this point. + if let Some(r) = poll_handle(&mut handle_a) { + println!("\n‼ A dropped before B even started: {r:?}"); + finish(stop, poller).await; + println!("\nVERDICT: INCONCLUSIVE (host A unstable on its own)"); + return Ok(()); + } + + // ---- Host B (the experiment) ------------------------------------------- + println!("\n[B] minting fresh tokens + connecting a SECOND host to the same id…"); + let t_b_start = Instant::now(); + let (b_host_tok, b_manage_tok) = mint_pair(&full_id)?; + let b = bring_up(&full_id, port, &b_host_tok, b_manage_tok).await; + + let (_host_b, handle_b) = match b { + Err(e) => { + let msg = e.to_string().to_lowercase(); + // A transient transport failure (Windows WSANO_DATA / DNS, websocket + // IO, EOF) is NOT a service decision — the same flaky lookup makes + // host A retry on connect too. Only a service-level refusal (a status + // code, "forbidden", "conflict", "already hosted") is a real reject. + let transient = msg.contains("11001") + || msg.contains("host não é conhecido") + || msg.contains("host not known") + || msg.contains("io error") + || msg.contains("eof") + || msg.contains("timed out"); + let rejected = msg.contains("403") + || msg.contains("409") + || msg.contains("forbidden") + || msg.contains("conflict") + || msg.contains("already"); + println!("[B] connect FAILED after {:?}: {e}", t_b_start.elapsed()); + let a_after = watch_for(&mut handle_a, Duration::from_secs(5)).await; + finish(stop, poller).await; + if rejected && !transient { + println!("\nVERDICT: REJECT — the relay refuses a second host on one tunnel id."); + println!( + " → make-before-break (connect-new-then-drop-old) is impossible as framed." + ); + println!(" → fall back to minimizing the break window on re-mint."); + } else { + println!( + "\nVERDICT: INCONCLUSIVE — B failed on a transient transport error, not a" + ); + println!( + " service rejection. Re-run; this is the same flaky DNS that retries on A." + ); + } + match a_after { + Some(r) => println!(" note: host A also dropped during B's attempt: {r:?}"), + None => println!(" note: host A kept serving through B's attempt."), + } + return Ok(()); + } + Ok(pair) => { + println!( + "[B] connected + port forwarded ✓ ({:?})", + t_b_start.elapsed() + ); + pair + } + }; + + // Both connect calls succeeded. Watch both handles for the verdict window. + // Measure any eviction delay from the moment B finished connecting (the + // handover gap that #46 cares about), not from when B started minting. + println!("\n[probe] both hosts connected — watching 15s for eviction…"); + tokio::pin!(handle_b); + let window = Duration::from_secs(15); + let watch_start = Instant::now(); + + let verdict = loop { + if watch_start.elapsed() >= window { + break Verdict::Coexist; + } + tokio::select! { + r = &mut handle_a => break Verdict::EvictOld(format!("{r:?}"), watch_start.elapsed()), + r = &mut handle_b => break Verdict::EvictNew(format!("{r:?}"), watch_start.elapsed()), + _ = tokio::time::sleep(Duration::from_millis(250)) => {} + } + }; + + finish(stop, poller).await; + + println!("\n──────────────────────────────────────────────"); + match verdict { + Verdict::Coexist => { + println!("VERDICT: COEXIST — two hosts served the same tunnel id for 15s."); + println!(" → make-before-break is CLEAN: connect new, verify serving, drop old."); + println!(" → GO on the #46 overlap rewrite of the re-mint path."); + } + Verdict::EvictOld(r, dt) => { + println!("VERDICT: EVICT (new evicts old) — host A dropped {dt:?} after B finished connecting."); + println!(" detail: A handle resolved with {r}"); + println!(" → make-before-break still works, but with a handover window."); + println!(" → GO, but measure the gap from the poll trace above before committing."); + } + Verdict::EvictNew(r, dt) => { + println!( + "VERDICT: EVICT (old evicts new) — host B dropped {dt:?} after finishing connect." + ); + println!(" detail: B handle resolved with {r}"); + println!(" → the service keeps the incumbent; a second host cannot take over live."); + println!( + " → NO-GO on make-before-break as framed; minimize the break window instead." + ); + } + } + println!("──────────────────────────────────────────────"); + Ok(()) +} + +enum Verdict { + Coexist, + EvictOld(String, Duration), + EvictNew(String, Duration), +} + +/// Non-blocking peek at a pinned relay handle: `Some(debug)` if it has resolved +/// (connection dropped), `None` if still live. +fn poll_handle( + handle: &mut std::pin::Pin<&mut tunnels::connections::RelayHandle>, +) -> Option { + use std::future::Future; + use std::task::{Context, Poll}; + let waker = futures_noop_waker(); + let mut cx = Context::from_waker(&waker); + match handle.as_mut().poll(&mut cx) { + Poll::Ready(r) => Some(format!("{r:?}")), + Poll::Pending => None, + } +} + +/// Awaits a handle for up to `dur`; returns `Some(debug)` if it resolved within +/// the window, `None` if it stayed live. +async fn watch_for( + handle: &mut std::pin::Pin<&mut tunnels::connections::RelayHandle>, + dur: Duration, +) -> Option { + tokio::select! { + r = handle.as_mut() => Some(format!("{r:?}")), + _ = tokio::time::sleep(dur) => None, + } +} + +/// Stops the poller and awaits its task so the final trace lines flush before the +/// verdict prints. +async fn finish(stop: Arc, poller: Option>) { + stop.store(true, Ordering::Relaxed); + if let Some(p) = poller { + let _ = p.await; + } +} + +/// A no-op waker so we can poll a future once without a runtime scheduling it. +fn futures_noop_waker() -> std::task::Waker { + use std::task::{RawWaker, RawWakerVTable, Waker}; + fn clone(_: *const ()) -> RawWaker { + RawWaker::new(std::ptr::null(), &VTABLE) + } + fn noop(_: *const ()) {} + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop); + unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } +} diff --git a/src/devtunnel.rs b/src/devtunnel.rs index 197978a..45c76fd 100644 --- a/src/devtunnel.rs +++ b/src/devtunnel.rs @@ -251,7 +251,10 @@ pub fn is_auth_error(stderr: &str) -> bool { /// A `400 Bad Request` from the tunnel management API is a request-validation /// failure — e.g. `add_port` rejected with "the tunnel port protocol cannot be /// changed" when the forwarded protocol disagrees with the registered one. These -/// are permanent for identical inputs. Auth failures are handled separately by +/// are permanent for identical inputs. A deleted or expired tunnel surfaces as +/// "Tunnel not found" / `404` while minting tokens; retrying that re-mint can +/// never succeed, so it must stop instead of spinning the reconnect loop forever +/// stuck on the `Authorizing` phase. Auth failures are handled separately by /// [`is_auth_error`] (they have a recovery path: re-login), so callers should /// check that first. #[cfg_attr(not(feature = "hosting"), allow(dead_code))] @@ -260,6 +263,18 @@ pub fn is_fatal_connect_error(stderr: &str) -> bool { lower.contains("400 bad request") || lower.contains("cannot be changed") || lower.contains("invalid arguments") + || is_missing_tunnel_error(stderr) +} + +/// Whether a host error means the tunnel itself no longer exists — `devtunnel +/// token` reports "Tunnel not found" / a `404` for a deleted or expired tunnel. +/// A strict subset of [`is_fatal_connect_error`]: the group should additionally +/// be dropped from the persisted auto-host set, since re-hosting it on the next +/// launch can never succeed. +#[cfg_attr(not(feature = "hosting"), allow(dead_code))] +pub fn is_missing_tunnel_error(stderr: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + lower.contains("not found") || lower.contains("404") } /// Runs `devtunnel user login` (interactive — opens the system browser and may @@ -784,8 +799,9 @@ fn tunnel_ports(show: ShowResult) -> Vec<(u16, String)> { mod tests { use super::{ anonymous_ace_args, classify_anonymous_access, classify_install_result, classify_user_show, - is_auth_error, parse_leading_int, parse_rate_bps, parse_size_bytes, sanitize_tunnel_id, - tunnel_ports, update_expiration_args, InstallOutcome, ShowResult, + is_auth_error, is_fatal_connect_error, is_missing_tunnel_error, parse_leading_int, + parse_rate_bps, parse_size_bytes, sanitize_tunnel_id, tunnel_ports, update_expiration_args, + InstallOutcome, ShowResult, }; #[test] @@ -1021,4 +1037,50 @@ mod tests { assert!(!is_auth_error("port number must be between 1 and 65535")); assert!(!is_auth_error("503 Service Unavailable")); } + + #[test] + fn fatal_on_request_validation_errors() { + assert!(is_fatal_connect_error( + "The request failed: 400 Bad Request" + )); + assert!(is_fatal_connect_error( + "the tunnel port protocol cannot be changed" + )); + assert!(is_fatal_connect_error("error: invalid arguments")); + } + + #[test] + fn fatal_on_deleted_or_missing_tunnel() { + // A deleted/expired tunnel surfaces while minting the host token; retrying + // can never succeed, so it must stop instead of looping on `Authorizing`. + assert!(is_fatal_connect_error( + "Tunnel not found in brs: fancy-ocean" + )); + assert!(is_fatal_connect_error( + "The request was rejected: 404 Not Found" + )); + } + + #[test] + fn not_fatal_on_transient_connect_errors() { + assert!(!is_fatal_connect_error("connection timed out")); + assert!(!is_fatal_connect_error("503 Service Unavailable")); + assert!(!is_fatal_connect_error("relay disconnected")); + } + + #[test] + fn missing_tunnel_detects_deleted_or_expired() { + // Drives the auto-host prune: only a genuinely-gone tunnel, not every + // fatal error (a 400 protocol mismatch must keep the group). + assert!(is_missing_tunnel_error( + "Tunnel not found in brs: fancy-ocean" + )); + assert!(is_missing_tunnel_error( + "The request was rejected: 404 Not Found" + )); + assert!(!is_missing_tunnel_error("400 Bad Request")); + assert!(!is_missing_tunnel_error( + "the tunnel port protocol cannot be changed" + )); + } } diff --git a/src/locale.rs b/src/locale.rs index ea30818..d5cc585 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -16,11 +16,16 @@ impl Locale { /// Loads the bundle for `lang` (e.g. `"en-US"`). /// Unknown locales fall back to `en-US`. pub fn load(lang: &str) -> Self { - let source = ftl_source(lang); + // The bundle's langid must match the FTL we actually load, not the raw + // request: it drives Fluent's CLDR plural selection. Loading en-US text + // under, say, a pt-BR langid applies Portuguese plural rules to English + // patterns — and pt classifies 0 as `one`, so `status-port-count` with + // count 0 wrongly rendered the `[one]` branch ("1 port") for "0 ports". + let resolved = resolve_lang(lang); + let source = ftl_source(resolved); let res = FluentResource::try_new(source.to_string()).expect("embedded FTL must be valid"); - let langid: LanguageIdentifier = lang - .parse() - .unwrap_or_else(|_| "en-US".parse().expect("en-US is valid")); + let langid: LanguageIdentifier = + resolved.parse().expect("resolved locale tag must be valid"); let mut bundle = FluentBundle::new(vec![langid]); bundle .add_resource(res) @@ -73,7 +78,71 @@ pub fn system_locale() -> String { sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string()) } -fn ftl_source(_lang: &str) -> &'static str { - // Add new locales here; unknown tags fall back to en-US. - include_str!("../i18n/en-US/app.ftl") +/// Resolves a requested BCP-47 tag to the locale we actually ship strings for, +/// so the loaded FTL and the bundle's langid (hence its plural rules) always +/// agree. Until more locales ship, every request resolves to en-US. Add an arm +/// here in lockstep with [`ftl_source`] when adding a locale. +fn resolve_lang(_lang: &str) -> &'static str { + // e.g. "pt-BR" | "pt" => "pt-BR", + "en-US" +} + +// Kept as a `match` on purpose: adding a locale is a one-line arm here (see the +// i18n section of CLAUDE.md), so we tolerate the single-binding form until a +// second locale ships. +#[allow(clippy::match_single_binding)] +fn ftl_source(lang: &str) -> &'static str { + // `lang` is already a resolved tag from [`resolve_lang`]. + match lang { + // "pt-BR" => include_str!("../i18n/pt-BR/app.ftl"), + _ => include_str!("../i18n/en-US/app.ftl"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_args(n: i64) -> FluentArgs<'static> { + let mut args = FluentArgs::new(); + args.set("count", n); + args + } + + /// Strips the bidi isolation marks (FSI/PDI) Fluent wraps around interpolated + /// args; they are invisible in the UI but would break literal comparisons. + fn plain(s: String) -> String { + s.replace(['\u{2068}', '\u{2069}'], "") + } + + #[test] + fn port_count_uses_english_plural_rules() { + // en-US: 0 and 2+ are "other", only 1 is "one". + let loc = Locale::load("en-US"); + assert_eq!( + plain(loc.t_args("status-port-count", &count_args(0))), + "0 ports" + ); + assert_eq!( + plain(loc.t_args("status-port-count", &count_args(1))), + "1 port" + ); + assert_eq!( + plain(loc.t_args("status-port-count", &count_args(3))), + "3 ports" + ); + } + + #[test] + fn pt_br_request_does_not_misplural_english_text() { + // Regression: a pt-BR system locale loaded en-US strings under a pt-BR + // langid, and pt classifies 0 as `one` — so "0 ports" rendered as the + // `[one]` branch ("1 port"). The bundle must use the resolved (en-US) + // langid so plural rules match the loaded text. + let loc = Locale::load("pt-BR"); + assert_eq!( + plain(loc.t_args("status-port-count", &count_args(0))), + "0 ports" + ); + } } diff --git a/src/logbuf.rs b/src/logbuf.rs index 997de6c..590eb4b 100644 --- a/src/logbuf.rs +++ b/src/logbuf.rs @@ -7,7 +7,9 @@ use log::{Level, LevelFilter, Log, Metadata, Record}; use std::collections::VecDeque; -use std::sync::Mutex; +use std::io::Write; +use std::sync::mpsc::{sync_channel, SyncSender}; +use std::sync::{Mutex, OnceLock}; /// Maximum number of captured lines kept in memory. const CAPACITY: usize = 500; @@ -57,6 +59,47 @@ impl Ring { static RING: Mutex = Mutex::new(Ring::new(CAPACITY)); +/// Bounded, non-blocking sink to a dedicated stderr writer thread. +/// +/// Teeing every record straight to stderr with `eprintln!` was a multi-day +/// freeze bug: `eprintln!` is a *blocking* write serialized by the global stderr +/// lock. When the app is launched from a terminal and that console pauses output +/// (a QuickEdit text selection) or its pipe backs up, the writing thread stalls +/// *holding the lock*; the next thread to log — eventually the UI thread — blocks +/// on it and the whole event loop freezes while the process stays alive. +/// +/// The writer thread owns the only blocking `writeln!`; every `log()` call just +/// `try_send`s the formatted line and drops it when the channel is full. A stuck +/// console can therefore stall at most this one background thread and cost a few +/// dropped log lines — never the UI thread. +static SINK: OnceLock> = OnceLock::new(); + +/// Capacity of the stderr writer channel. While the console is paused, lines +/// beyond this are dropped rather than blocking (or unboundedly growing) the +/// threads that emit them. +const SINK_CAPACITY: usize = 1024; + +/// Spawns the background stderr writer thread and stores its non-blocking sender. +/// First caller wins (subsequent calls are no-ops); safe to call once from +/// [`CaptureLogger::install`]. +fn init_stderr_writer() { + let (tx, rx) = sync_channel::(SINK_CAPACITY); + if SINK.set(tx).is_err() { + return; // already initialized + } + let _ = std::thread::Builder::new() + .name("devtunnel-log-writer".to_string()) + .spawn(move || { + let mut out = std::io::stderr(); + // A blocking write here (paused/stuck console) stalls only this + // thread; the bounded channel drops new lines meanwhile, so no + // logging thread ever waits on stderr. + while let Ok(line) = rx.recv() { + let _ = writeln!(out, "{line}"); + } + }); +} + /// Appends a record to the process-wide ring buffer. /// Dormant in v0.1.0 (Logs-tab capture disabled); kept for re-enable + tests. #[allow(dead_code)] @@ -128,6 +171,8 @@ impl CaptureLogger { /// Installs `self` as the global logger and sets the max level to the most /// verbose directive. Errors only if a logger is already installed. pub fn install(self) -> Result<(), log::SetLoggerError> { + // Start the decoupled stderr writer before any record can be emitted. + init_stderr_writer(); let max = self .directives .iter() @@ -166,7 +211,12 @@ impl Log for CaptureLogger { let message = record.args().to_string(); // Technical/diagnostic content — intentionally not localized. let line = format!("{:<5} {} — {}", record.level(), record.target(), message); - eprintln!("{line}"); + // Hand the line to the writer thread without ever blocking: a full + // channel (paused/stuck console) drops the line instead of stalling this + // — possibly the UI — thread. See `SINK` for why this matters. + if let Some(sink) = SINK.get() { + let _ = sink.try_send(line); + } // Logs-tab capture DISABLED for stability (v0.1.0): the detail panel's // Logs view is turned off, so records are no longer accumulated in the // ring (only stderr above is kept). Restore with the panel. diff --git a/src/main.rs b/src/main.rs index 2efb35b..f005030 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod model; #[cfg(feature = "hosting")] mod probe; mod state; +mod update; mod view; slint::include_modules!(); @@ -247,6 +248,13 @@ fn main() -> anyhow::Result<()> { let (host_evt_tx, host_evt_rx) = std::sync::mpsc::channel::(); let tunnel_host = host::spawn(host_evt_tx); + // ---- Update checker ---- + // A background thread polls GitHub Releases (startup + every 24 h) and pumps + // an UpdateInfo when a newer version than this build is published; the UI + // pump then shows the in-app update banner. + let (update_tx, update_rx) = std::sync::mpsc::channel::(); + update::spawn(update_tx); + #[cfg(feature = "hosting")] let (probe_evt_rx, probe_cmd_tx) = { let (probe_evt_tx, probe_evt_rx) = std::sync::mpsc::channel::(); @@ -322,6 +330,28 @@ fn main() -> anyhow::Result<()> { }); }); } + // ---- Update banner: open the release page in the browser ---- + { + let weak = app.as_weak(); + app.on_open_update_url(move || { + if let Some(a) = weak.upgrade() { + open_browser(&a.get_update_url()); + } + }); + } + // ---- Update banner: ignore this version (persist + hide the banner) ---- + { + let weak = app.as_weak(); + let app_state = app_state.clone(); + app.on_ignore_update(move || { + if let Some(a) = weak.upgrade() { + let mut st = app_state.borrow_mut(); + st.settings.skipped_update = a.get_update_version().to_string(); + st.save(); + a.set_update_available(false); + } + }); + } // ---- Settings: probe interval + default expiration (issue #6) ---- // Seed the dialog properties from the persisted settings; the handlers // persist edits and (hosting build) re-target the live probe immediately. @@ -831,6 +861,22 @@ fn main() -> anyhow::Result<()> { toggle_window(&weak); } } + // A newer GitHub release was found -> show the update banner, + // unless the user already chose to ignore exactly this version. + while let Ok(info) = update_rx.try_recv() { + if info.version == app_state.borrow().settings.skipped_update { + continue; + } + if let Some(a) = weak.upgrade() { + let mut args = FluentArgs::new(); + args.set("version", info.version.clone()); + a.global::() + .set_update_banner_body(loc.t_args("update-banner-body", &args).into()); + a.set_update_version(info.version.into()); + a.set_update_url(info.url.into()); + a.set_update_available(true); + } + } // CLI install outcomes -> clear "Installing…" and surface a // clear result instead of swallowing failures. while let Ok(outcome) = install_rx.try_recv() { @@ -960,6 +1006,21 @@ fn main() -> anyhow::Result<()> { update_tray_icon(&tray, "relogin"); } } + // The tunnel was deleted/expired mid-session: drop + // it from the persisted auto-host set so the next + // launch does not retry a host that can never + // succeed (the loop that left it stuck on + // "authorizing…"). + if devtunnel::is_missing_tunnel_error(msg) { + let mut ps = app_state.borrow_mut(); + if ps.contains_auto_host(&tunnel_id) { + ps.remove_auto_host(&tunnel_id); + ps.save(); + log::info!( + "host: {tunnel_id} no longer exists; removed from auto-host set" + ); + } + } } let id = map_host_state(&hs); let mut st = state.borrow_mut(); @@ -1016,8 +1077,11 @@ fn main() -> anyhow::Result<()> { let ids = app_state.borrow().auto_host.clone(); if !ids.is_empty() { let mut st = state.borrow_mut(); + let mut pruned = false; for id in &ids { - let known = st.rows.iter().any(|r| &r.tunnel_id == id && r.port > 0); + let exists = st.rows.iter().any(|r| &r.tunnel_id == id); + let known = + exists && st.rows.iter().any(|r| &r.tunnel_id == id && r.port > 0); if known { log::info!("auto-resume: hosting {id}"); tunnel_host.send(host::HostCommand::Host { @@ -1025,10 +1089,22 @@ fn main() -> anyhow::Result<()> { }); st.host.insert(id.clone(), "host".to_string()); host_changed = true; + } else if !exists { + // The tunnel no longer exists (deleted/expired + // while the app was closed): drop it so we stop + // carrying a dead entry across launches. + log::info!( + "auto-resume: {id} no longer exists; removing from auto-host set" + ); + app_state.borrow_mut().remove_auto_host(id); + pruned = true; } else { - log::info!("auto-resume: skipping unknown or portless group {id}"); + log::info!("auto-resume: skipping portless group {id}"); } } + if pruned { + app_state.borrow().save(); + } } } @@ -1669,6 +1745,9 @@ fn build_tray_menu( /// Call once after constructing `AppWindow`, before showing the UI. fn apply_strings(app: &AppWindow, loc: &Locale) { let s = app.global::(); + // Store (MSIX) builds hide the self-install / uninstall / auto-start controls; + // the package manages those. Compile-time constant so it is stripped in each build. + s.set_store_build(cfg!(feature = "store")); s.set_status_loading(loc.t("status-loading").into()); s.set_status_refreshing(loc.t("status-refreshing").into()); s.set_btn_refresh(loc.t("btn-refresh").into()); @@ -1747,6 +1826,13 @@ fn apply_strings(app: &AppWindow, loc: &Locale) { s.set_banner_relogin_body(loc.t("banner-relogin-body").into()); s.set_btn_sign_in(loc.t("btn-sign-in").into()); s.set_banner_action_open_settings(loc.t("banner-action-open-settings").into()); + + // Update available banner (update-banner-body is filled from Rust with the + // release version when a newer release is found). + s.set_update_banner_title(loc.t("update-banner-title").into()); + s.set_btn_update_download(loc.t("btn-update-download").into()); + s.set_btn_update_ignore(loc.t("btn-update-ignore").into()); + s.set_install_status_running(loc.t("install-status-running").into()); s.set_install_status_done(loc.t("install-status-done").into()); s.set_install_status_elevation(loc.t("install-status-elevation").into()); diff --git a/src/state.rs b/src/state.rs index 116309c..4903819 100644 --- a/src/state.rs +++ b/src/state.rs @@ -31,6 +31,10 @@ pub struct Settings { /// Minimum severity shown in the port-detail Logs tab: one of /// `error`/`warn`/`info`/`debug`. Defaults to `info` (Debug chatter hidden). pub log_level: String, + /// A release the user chose to ignore via the update banner's "Ignore" + /// button (the release tag, e.g. `v0.2.0`). The banner stays hidden for + /// exactly this version; a later release still notifies. Empty = none. + pub skipped_update: String, } impl Default for Settings { @@ -45,6 +49,8 @@ impl Default for Settings { dark: None, // Show info and above by default; users can widen to debug. log_level: "info".to_string(), + // No release ignored until the user clicks "Ignore" on the banner. + skipped_update: String::new(), } } } @@ -152,17 +158,52 @@ fn atomic_write(path: &Path, content: &str) -> anyhow::Result<()> { Ok(()) } -/// Loads the cached rows from the last successful load. Missing or invalid -/// content yields an empty list (the async refresh reconciles shortly after). +/// Discard the instant-paint cache once it is older than this. The cache only +/// exists to paint the last load immediately on a quick relaunch; after a long +/// gap a tunnel deleted meanwhile would otherwise flash as a phantom row for the +/// seconds the live `devtunnel list` takes to land, so we wait for the live load +/// instead. +const CACHE_MAX_AGE_SECS: u64 = 24 * 60 * 60; + +/// The row cache on disk: the last successful load plus when it was written, so +/// a stale cache can be skipped on startup. +#[derive(Debug, Serialize, Deserialize)] +struct RowCache { + /// Unix seconds at write time. + saved_at: u64, + rows: Vec, +} + +/// Current wall-clock time in Unix seconds (0 if the clock predates the epoch). +fn now_unix_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Loads the cached rows from the last successful load. Missing, invalid, or +/// stale content yields an empty list (the async refresh reconciles shortly +/// after). pub fn load_row_cache() -> Vec { - load_row_cache_from(&cache_path()) + load_row_cache_from(&cache_path(), now_unix_secs()) } -fn load_row_cache_from(path: &Path) -> Vec { - match fs::read_to_string(path) { - Ok(text) => serde_json::from_str(&text).unwrap_or_default(), - Err(_) => Vec::new(), +fn load_row_cache_from(path: &Path, now: u64) -> Vec { + let Ok(text) = fs::read_to_string(path) else { + return Vec::new(); + }; + // An unparseable (or pre-timestamp) cache is simply ignored; the live load + // rewrites it in the new format. + let Ok(cache) = serde_json::from_str::(&text) else { + return Vec::new(); + }; + // Skip a cache past its TTL. `saturating_sub` also drops a cache with a + // future timestamp (clock skew), which would otherwise look fresh forever. + if now.saturating_sub(cache.saved_at) > CACHE_MAX_AGE_SECS { + return Vec::new(); } + cache.rows } /// Persists the rows of a successful load so the next startup can paint the @@ -172,7 +213,11 @@ pub fn save_row_cache(rows: &[crate::devtunnel::Row]) { } fn save_row_cache_to(path: &Path, rows: &[crate::devtunnel::Row]) { - let result = serde_json::to_string(rows) + let cache = RowCache { + saved_at: now_unix_secs(), + rows: rows.to_vec(), + }; + let result = serde_json::to_string(&cache) .map_err(anyhow::Error::from) .and_then(|json| atomic_write(path, &json)); if let Err(e) = result { @@ -262,7 +307,7 @@ mod tests { // Missing file -> empty list. let _ = fs::remove_file(&path); - assert!(load_row_cache_from(&path).is_empty()); + assert!(load_row_cache_from(&path, now_unix_secs()).is_empty()); let rows = vec![crate::devtunnel::Row { group: "frontend".into(), @@ -274,14 +319,20 @@ mod tests { host_connections: 0, }]; save_row_cache_to(&path, &rows); - let loaded = load_row_cache_from(&path); + let loaded = load_row_cache_from(&path, now_unix_secs()); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].tunnel_id, "frontend.brs"); assert_eq!(loaded[0].port, 3000); - // Invalid content -> empty list. + // Past the TTL -> skipped so a deleted tunnel cannot flash on startup. + let stale = now_unix_secs() + CACHE_MAX_AGE_SECS + 1; + assert!(load_row_cache_from(&path, stale).is_empty()); + + // Invalid content (and the old pre-timestamp array format) -> empty list. fs::write(&path, "garbage").unwrap(); - assert!(load_row_cache_from(&path).is_empty()); + assert!(load_row_cache_from(&path, now_unix_secs()).is_empty()); + fs::write(&path, r#"[{"tunnel_id":"x.brs","port":1}]"#).unwrap(); + assert!(load_row_cache_from(&path, now_unix_secs()).is_empty()); } #[test] diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..4cb9596 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,167 @@ +//! Background check for a newer GitHub release. +//! +//! On startup and every 24 h thereafter, a background thread queries the GitHub +//! Releases API for the latest published release and compares its tag against +//! the running build's `GIT_VERSION`. When the release is strictly newer it +//! sends an `UpdateInfo` to the UI thread, which surfaces an in-app banner. +//! +//! The check is best-effort: network failures are logged at debug and retried +//! on the next tick — they never surface to the user or block the UI. + +use std::sync::mpsc::Sender; +#[cfg(not(feature = "store"))] +use std::time::Duration; + +/// GitHub Releases API for this repo's latest (non-prerelease) release. +#[cfg(not(feature = "store"))] +const RELEASES_API: &str = + "https://api.github.com/repos/paulocorcino/devtunnel_gui/releases/latest"; + +/// Public release page, used as the click target when the API omits `html_url`. +#[cfg(not(feature = "store"))] +const RELEASES_PAGE: &str = "https://github.com/paulocorcino/devtunnel_gui/releases/latest"; + +/// How often to re-check after the initial startup check. The app is a tray +/// app that can stay open for days, so a one-shot startup check could never +/// fire for long-running instances. +#[cfg(not(feature = "store"))] +const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// A release newer than the running build, pumped to the UI thread. +#[derive(Clone, Debug)] +// In `store` builds the checker is a no-op, so the fields are never read; the +// type is still referenced by `spawn`'s channel signature. +#[cfg_attr(feature = "store", allow(dead_code))] +pub struct UpdateInfo { + /// The release tag, as published (e.g. `v0.2.0`). + pub version: String, + /// The release page to open in the browser. + pub url: String, +} + +/// Spawns the background update checker. Sends an `UpdateInfo` on the channel +/// whenever the latest release is newer than the running build, then sleeps +/// until the next check. Stops when the receiver is dropped (UI shut down). +#[cfg(feature = "store")] +pub fn spawn(_tx: Sender) { + // Store (MSIX) builds are updated through the Microsoft Store, not GitHub + // Releases. Self-directed update prompts are against Store policy, so the + // checker is compiled out entirely — the banner never fires. +} + +#[cfg(not(feature = "store"))] +pub fn spawn(tx: Sender) { + // Test hook: force the banner without a live release. `DEVTUNNEL_FAKE_UPDATE` + // is the tag to advertise (e.g. `v9.9.9`); the URL points at the releases + // page. Used to verify the banner UI locally. + if let Ok(tag) = std::env::var("DEVTUNNEL_FAKE_UPDATE") { + let _ = tx.send(UpdateInfo { + version: tag, + url: RELEASES_PAGE.to_string(), + }); + return; + } + + let current = env!("GIT_VERSION"); + std::thread::spawn(move || loop { + match check_latest() { + Ok(Some(info)) if is_newer(&info.version, current) => { + if tx.send(info).is_err() { + return; // Receiver gone — the UI is shutting down. + } + } + Ok(_) => {} + Err(e) => log::debug!("update check failed: {e}"), + } + std::thread::sleep(CHECK_INTERVAL); + }); +} + +/// Queries the GitHub API for the latest release. Returns `Ok(None)` when the +/// response carries no usable tag. +#[cfg(not(feature = "store"))] +fn check_latest() -> anyhow::Result> { + let resp = ureq::get(RELEASES_API) + // GitHub rejects requests without a User-Agent. + .set("User-Agent", "devtunnel_gui") + .set("Accept", "application/vnd.github+json") + .timeout(Duration::from_secs(10)) + .call()?; + // ureq's `json` feature is off (keeps the default build light); parse the + // body with serde_json directly. + let json: serde_json::Value = serde_json::from_str(&resp.into_string()?)?; + let tag = json.get("tag_name").and_then(|v| v.as_str()).unwrap_or(""); + if tag.is_empty() { + return Ok(None); + } + let url = json + .get("html_url") + .and_then(|v| v.as_str()) + .unwrap_or(RELEASES_PAGE) + .to_string(); + Ok(Some(UpdateInfo { + version: tag.to_string(), + url, + })) +} + +/// Returns true when `candidate` is a strictly newer semantic version than +/// `current`. Both may carry a leading `v` and `-`/`+` build suffixes +/// (e.g. `v0.2.0`, `0.1.0+g05b8b3c-dirty`); only MAJOR.MINOR.PATCH is compared. +/// Anything that cannot be parsed is treated as not-newer (fail closed), so a +/// malformed tag never triggers a spurious "update available". +#[cfg(not(feature = "store"))] +fn is_newer(candidate: &str, current: &str) -> bool { + match (parse_semver(candidate), parse_semver(current)) { + (Some(c), Some(cur)) => c > cur, + _ => false, + } +} + +/// Extracts `(major, minor, patch)` from a version string, ignoring a leading +/// `v` and any `-`/`+` suffix. Missing minor/patch default to 0. Returns `None` +/// if the numeric core is absent or non-numeric. +#[cfg(not(feature = "store"))] +fn parse_semver(s: &str) -> Option<(u64, u64, u64)> { + let core = s.trim().trim_start_matches(['v', 'V']); + let core = core.split(['-', '+']).next().unwrap_or(""); + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + Some((major, minor, patch)) +} + +#[cfg(all(test, not(feature = "store")))] +mod tests { + use super::*; + + #[test] + fn newer_detects_bump() { + assert!(is_newer("v0.2.0", "0.1.0+g05b8b3c")); + assert!(is_newer("v0.1.1", "v0.1.0")); + assert!(is_newer("1.0.0", "v0.9.9")); + } + + #[test] + fn not_newer_when_equal_or_older() { + // Untagged dev build of the same release must not self-notify. + assert!(!is_newer("v0.1.0", "0.1.0+g05b8b3c")); + assert!(!is_newer("v0.1.0", "v0.2.0")); + // Commits past the tag on the same MAJOR.MINOR.PATCH are not newer. + assert!(!is_newer("v0.2.0", "v0.2.0-3-gabc1234")); + } + + #[test] + fn unparseable_is_not_newer() { + assert!(!is_newer("nightly", "v0.1.0")); + assert!(!is_newer("v0.2.0", "not-a-version")); + } + + #[test] + fn parses_suffixes() { + assert_eq!(parse_semver("v0.2.0-3-gabc1234"), Some((0, 2, 0))); + assert_eq!(parse_semver("0.1.0+g05b8b3c-dirty"), Some((0, 1, 0))); + assert_eq!(parse_semver("v1.2"), Some((1, 2, 0))); + } +} diff --git a/ui/app-window.slint b/ui/app-window.slint index 6116445..d6b23a3 100644 --- a/ui/app-window.slint +++ b/ui/app-window.slint @@ -9,6 +9,7 @@ import { GroupCard, GroupView, PortView } from "group-card.slint"; import { Toast } from "toast.slint"; import { EmptyState } from "empty-state.slint"; import { PreflightBanner } from "banner.slint"; +import { UpdateBanner } from "update-banner.slint"; import { SettingsDialog } from "settings.slint"; export { Strings, Theme, GroupView, PortView } @@ -77,6 +78,16 @@ export component AppWindow inherits Window { // App version string (from Cargo), shown in the About panel. in property app-version; + // Update available: set by Rust when the background checker finds a newer + // GitHub release. Drives the UpdateBanner at the top of the window. + in property update-available; + in property update-version; + in property update-url; + // The user clicked "View release" — Rust opens `update-url` in the browser. + callback open-update-url(); + // The user clicked "Ignore" — Rust persists `update-version` as skipped. + callback ignore-update(); + // Settings → Requirements checklist: per-item satisfied/not flags, refreshed // by Rust when the dialog opens and after install/auto-start actions. in property req-cli-ok; @@ -208,6 +219,17 @@ export component AppWindow inherits Window { } Rectangle { height: 1px; background: Theme.border; } + // ---- Update available banner (newer GitHub release) ---- + if root.update-available : UpdateBanner { + url: root.update-url; + view-release => { + root.open-update-url(); + } + ignore-update => { + root.ignore-update(); + } + } + // ---- Preflight banner (CLI missing / re-login) — issue #14 ---- if root.app-state != "ready" : PreflightBanner { app-state: root.app-state; diff --git a/ui/port-row.slint b/ui/port-row.slint index a9f3e8c..df5d71b 100644 --- a/ui/port-row.slint +++ b/ui/port-row.slint @@ -1,5 +1,5 @@ // One port inside a GroupCard: status dot · port · protocol pill · -// prominent monospace URL (click-to-copy) · hover actions (copy ⧉, open ↗) +// prominent monospace URL (click-to-open) · hover actions (copy ⧉) // and a de-emphasised delete action. import { Theme } from "theme.slint"; import { Strings } from "strings.slint"; @@ -47,7 +47,7 @@ export component PortRow inherits Rectangle { // Including the buttons' own hover prevents the flicker that a bare // `hover.has-hover` produces when the pointer moves onto an action. property show-actions: hover.has-hover || copy-btn.hovered - || open-btn.hovered || del-btn.hovered || root.always-show-actions; + || del-btn.hovered || root.always-show-actions; height: Theme.row-height; background: root.selected @@ -92,16 +92,16 @@ export component PortRow inherits Rectangle { Pill { text: pv.protocol == "" ? "—" : pv.protocol; } } - // The URL is the product: prominent, monospace, click-to-copy. Only the - // text itself copies on click; the trailing strip toggles the detail - // panel so metrics/logs stay reachable even when the URL is long. + // The URL is the product: prominent, monospace, click-to-open. Only the + // text itself opens in the browser on click; the trailing strip toggles + // the detail panel so metrics/logs stay reachable even when the URL is long. HorizontalLayout { horizontal-stretch: 1; url-area := TouchArea { enabled: pv.url != ""; mouse-cursor: pv.url == "" ? MouseCursor.default : MouseCursor.pointer; clicked => { - root.copy-url(pv.url); + root.open-url(pv.url); } Text { text: pv.url == "" ? Strings.no-url : pv.url; @@ -138,15 +138,6 @@ export component PortRow inherits Rectangle { root.copy-url(pv.url); } } - open-btn := IconButton { - glyph: Theme.ico-open; - tip: Strings.tooltip-open; - revealed: root.show-actions; - enabled: pv.url != ""; - clicked => { - root.open-url(pv.url); - } - } del-btn := IconButton { glyph: Theme.ico-delete; tip: Strings.btn-del-port; diff --git a/ui/settings.slint b/ui/settings.slint index cef0197..dd19a81 100644 --- a/ui/settings.slint +++ b/ui/settings.slint @@ -217,7 +217,9 @@ export component SettingsDialog inherits Rectangle { // -- General -- if root.section == 0 : VerticalLayout { spacing: Theme.gap; - Check { + // Auto-start is managed by the MSIX package (windows.startupTask, + // toggled in Windows Settings) in Store builds, so hide this toggle there. + if !Strings.store-build: Check { text: Strings.field-auto-start; checked <=> root.auto-start; toggled(on) => { @@ -269,7 +271,10 @@ export component SettingsDialog inherits Rectangle { ReqRow { ok: root.req-cli-ok; label: Strings.req-cli; - if !root.req-cli-ok: TxtButton { + // The winget-based installer can't run from the MSIX sandbox + // (and invoking external installers is against Store policy), + // so Store builds omit the button; the row still flags the CLI. + if !root.req-cli-ok && !Strings.store-build: TxtButton { text: root.installing ? Strings.install-status-running : Strings.btn-install-cli; enabled: !root.installing; clicked => { @@ -289,30 +294,33 @@ export component SettingsDialog inherits Rectangle { } } } - ReqRow { + // Install / shortcut / auto-start state and the uninstall action + // only apply to the portable build. In Store builds the MSIX + // package owns install and removal, so hide this whole block. + if !Strings.store-build: ReqRow { ok: root.req-installed-ok; label: Strings.req-installed; } - ReqRow { + if !Strings.store-build: ReqRow { ok: root.req-shortcut-ok; label: Strings.req-shortcut; } - ReqRow { + if !Strings.store-build: ReqRow { ok: root.req-autostart-ok; label: Strings.req-autostart; } - Text { + if !Strings.store-build: Text { text: Strings.req-install-hint; color: Theme.muted; font-size: Theme.fs-caption; wrap: word-wrap; } // Danger zone: uninstall (only meaningful once installed). - if root.req-installed-ok: Rectangle { + if !Strings.store-build && root.req-installed-ok: Rectangle { height: 1px; background: Theme.border; } - if root.req-installed-ok: HorizontalLayout { + if !Strings.store-build && root.req-installed-ok: HorizontalLayout { alignment: start; TxtButton { text: Strings.btn-uninstall; diff --git a/ui/strings.slint b/ui/strings.slint index 54efdaf..a582250 100644 --- a/ui/strings.slint +++ b/ui/strings.slint @@ -2,6 +2,12 @@ // app.global::().set_*(...). Default values are English fallbacks // so the UI renders correctly even before the locale is applied. export global Strings { + // True in Microsoft Store (MSIX) builds. Hides the self-install, uninstall, + // and HKCU auto-start controls, which the package manages itself (auto-start + // is declared via the manifest's windows.startupTask and toggled in Windows + // Settings > Startup apps). Set from Rust; defaults false for the portable build. + in property store-build: false; + // Status bar in property status-loading: "loading…"; in property status-refreshing: "refreshing…"; @@ -75,7 +81,7 @@ export global Strings { in property badge-hosting: "Hosting…"; // Top bar (redesign) - in property app-title: "Dev Tunnels"; + in property app-title: "TunnelDeck"; in property pill-connected: "Connected"; in property tooltip-settings: "Toggle dark mode"; @@ -132,7 +138,7 @@ export global Strings { // About in property about-title: "About"; - in property about-app-name: "Dev Tunnels GUI"; + in property about-app-name: "TunnelDeck for Dev Tunnels"; in property about-version-label: "Version"; in property about-tagline: "Manage Microsoft Dev Tunnels from your Windows tray."; in property about-built-on: "Built on Microsoft Dev Tunnels — Microsoft's free, security-focused tunneling service — and its official CLI and SDK. Not affiliated with or endorsed by Microsoft."; @@ -151,6 +157,13 @@ export global Strings { in property btn-sign-in: "Sign in"; in property banner-action-open-settings: "Open Settings"; + // Update available banner (update-banner-body is filled from Rust with the + // release version, so it has no static default). + in property update-banner-title: "Update available"; + in property update-banner-body; + in property btn-update-download: "View release"; + in property btn-update-ignore: "Ignore"; + // Install CLI progress / outcome in property install-status-running: "Installing…"; in property install-status-done: "Dev Tunnels CLI installed"; diff --git a/ui/update-banner.slint b/ui/update-banner.slint new file mode 100644 index 0000000..e1337e0 --- /dev/null +++ b/ui/update-banner.slint @@ -0,0 +1,63 @@ +// In-app banner shown at the top of the window when a newer GitHub release is +// available. Unlike the PreflightBanner it is not tied to the app-state enum — +// it is gated by its own `update-available` flag and can show while ready. +import { Theme } from "theme.slint"; +import { Strings } from "strings.slint"; +import { TxtButton } from "controls.slint"; + +export component UpdateBanner inherits Rectangle { + // Release page opened when the user clicks the action button (Rust reads it + // back from the window; kept here only for symmetry / future use). + in property url; + // User clicked "View release" — Rust opens the release page in the browser. + callback view-release(); + // User clicked "Ignore" — Rust remembers this version and hides the banner. + callback ignore-update(); + + background: Theme.surface; + + VerticalLayout { + HorizontalLayout { + padding: Theme.pad; + spacing: Theme.gap; + + VerticalLayout { + spacing: 2px; + alignment: center; + Text { + text: Strings.update-banner-title; + color: Theme.accent; + font-size: Theme.fs-section; + font-weight: 700; + } + Text { + text: Strings.update-banner-body; + color: Theme.text; + font-size: Theme.fs-body; + wrap: word-wrap; + } + } + + Rectangle { horizontal-stretch: 1; } + + VerticalLayout { + alignment: center; + spacing: Theme.gap-sm; + TxtButton { + text: Strings.btn-update-download; + primary: true; + clicked => { + root.view-release(); + } + } + TxtButton { + text: Strings.btn-update-ignore; + clicked => { + root.ignore-update(); + } + } + } + } + Rectangle { height: 1px; background: Theme.border; } + } +}