From 288463341573b851382a7415258e612398be0f71 Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Thu, 17 Sep 2026 10:04:03 +0100 Subject: [PATCH 1/2] Open account-linking links in the host browser TPV's Connect Garmin button (and Strava, TrainingPeaks, intervals.icu, etc.) calls OpenURL with an OAuth page whose redirect goes to api.indievelo.com, so no local callback is involved. Under Wine that reaches xdg-open, which had no browser in the container, so the click did nothing. The image's xdg-open now forwards http(s)/mailto links to the host through the OpenURI portal over the host session bus, which run-tpv.sh mounts. If the portal is unreachable it prints the link to open by hand. Refs #6 --- Containerfile.full | 5 ++++- README.md | 4 +++- run-tpv.sh | 10 ++++++++++ scripts/xdg-open-host.sh | 30 ++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100755 scripts/xdg-open-host.sh diff --git a/Containerfile.full b/Containerfile.full index 7f7e02f..4db7ba0 100644 --- a/Containerfile.full +++ b/Containerfile.full @@ -60,6 +60,7 @@ RUN dpkg --add-architecture i386 \ libqt5texttospeech5 libqt5websockets5 libqt5widgets5 libqt5xml5 \ qml-module-qtquick2 \ libvulkan1 libvulkan1:i386 vulkan-tools \ + libglib2.0-bin \ && rm -rf /var/lib/apt/lists/* # Stable's Mesa 25.0.7 is the prime suspect for mid-ride i915 GPU hangs (#5). @@ -92,7 +93,9 @@ COPY --from=qzbuild /build/qdomyos-zwift /opt/qz/qdomyos-zwift RUN useradd -m -u 1000 -s /bin/bash tpv \ && mkdir -p /opt/tpv && chown tpv:tpv /opt/tpv COPY scripts/ /opt/scripts/ -RUN chmod +x /opt/scripts/*.sh +# Wine opens links via xdg-open; this one forwards them to the host browser. +RUN chmod +x /opt/scripts/*.sh \ + && ln -s /opt/scripts/xdg-open-host.sh /usr/local/bin/xdg-open USER tpv ENV WINEPREFIX=/opt/tpv/wine-template \ diff --git a/README.md b/README.md index 00f5522..8ead1b2 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ it to the trainer. Set `QZ_HR_BELT` and pair `Wahoo HRM` in TPV explicitly. | `Containerfile.debian` | QZ only, built from source (bridge on a separate host) | | `patches/` | The QZ root-guard patch, published as GPL requires | | `scripts/entrypoint.sh` | Mode dispatch, prefix seeding, QZ + TPV startup | +| `scripts/xdg-open-host.sh` | Opens links from TPV in the host browser | | `scripts/build-prefix.sh` | Builds the template Wine prefix at image build time | | `run-tpv.sh` | GPU, display and audio wiring for the combined image | | `run-qz-debian.sh` | Runs the QZ-only image | @@ -184,6 +185,7 @@ it to the trainer. Set `QZ_HR_BELT` and pair `Wahoo HRM` in TPV explicitly. | writable `HOME` and workdir | QZ spins on failed debug-log writes and never gets past discovery. Looks like a hang | | D-Bus socket mount | No BlueZ at all: `Cannot find a running Bluez` | | Avahi socket mount | No mDNS advert, so TPV never discovers the bridge | +| Session bus mount | Connect Garmin / Strava / TrainingPeaks buttons do nothing: Wine's `xdg-open` has no browser to reach. The image's `xdg-open` forwards links to the host browser through the OpenURI portal | | `BLUETOOTH_FORCE_DBUS_LE_VERSION` | Qt probes bluetoothd's version by executing its path *inside* the container, where it is absent; the `"4.0"` fallback selects a legacy raw-L2CAP backend that cannot connect | QZ needs **no root**. Its upstream `getuid()` guard predates QZ reaching BlueZ @@ -192,7 +194,7 @@ check, which is why a patch rather than `--cap-add` is required. ## Scope -This is packaging, not sandboxing. Host networking, host D-Bus and host Avahi mean +This is packaging, not sandboxing. Host networking, host D-Bus (system and session) and host Avahi mean the container is tightly coupled to the host. What it removes is installing a Qt5 build environment, compiling QZ, and assembling a working Wine prefix by hand. diff --git a/run-tpv.sh b/run-tpv.sh index 9e9af13..6a9d874 100755 --- a/run-tpv.sh +++ b/run-tpv.sh @@ -68,6 +68,15 @@ HOST_TZ="${TZ:-$(timedatectl show -p Timezone --value 2>/dev/null || true)}" [ -e /etc/localtime ] && TIMEZONE+=(-v /etc/localtime:/etc/localtime:ro) echo "==> timezone: ${HOST_TZ:-host /etc/localtime}" +HOST_LINKS=() +SESSION_BUS="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/bus" +if [ -S "$SESSION_BUS" ]; then + HOST_LINKS=(-v "$SESSION_BUS:/run/host-session-bus" + -e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/host-session-bus) +else + echo "==> WARNING: no session bus; account-linking buttons cannot open a browser" >&2 +fi + INHIBIT=() if [ "$MODE" != qz ] && command -v gnome-session-inhibit >/dev/null; then INHIBIT=(gnome-session-inhibit --inhibit idle:suspend @@ -83,6 +92,7 @@ exec "${INHIBIT[@]}" podman run --rm --name tpv \ "${GPU[@]}" "${AUDIO[@]}" \ "${DISPLAY_ARGS[@]}" \ "${TIMEZONE[@]}" \ + "${HOST_LINKS[@]}" \ -e "QZ_TRAINER=${TRAINER}" \ -e "QZ_HR_BELT=${HR_BELT}" \ -e "TPV_DIRECT=${TPV_DIRECT:-0}" \ diff --git a/scripts/xdg-open-host.sh b/scripts/xdg-open-host.sh new file mode 100755 index 0000000..4ad0b74 --- /dev/null +++ b/scripts/xdg-open-host.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# xdg-open for the container: hands links to the host desktop's browser through +# the OpenURI portal on the host session bus (mounted by run-tpv.sh). +set -uo pipefail + +URL="${1:-}" +if [ -z "$URL" ]; then + echo "usage: xdg-open " >&2 + exit 1 +fi + +case "$URL" in + http://*|https://*|mailto:*) ;; + *) echo "==> xdg-open: not forwarding unsupported link: $URL" >&2; exit 1 ;; +esac + +if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then + echo "!! xdg-open: no host session bus; open this link manually: $URL" >&2 + exit 1 +fi + +if ! ERR=$(gdbus call --session \ + --dest org.freedesktop.portal.Desktop \ + --object-path /org/freedesktop/portal/desktop \ + --method org.freedesktop.portal.OpenURI.OpenURI \ + "" "$URL" "{}" 2>&1 >/dev/null); then + echo "!! xdg-open: host portal refused ($ERR); open this link manually: $URL" >&2 + exit 1 +fi +echo "==> opened in host browser: ${URL%%\?*}" >&2 From 6a8bcd14b833a7380693fa1ae7f77fd277cd6b23 Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Thu, 17 Sep 2026 12:05:53 +0100 Subject: [PATCH 2/2] ADR-0004: open account-linking links via the host OpenURI portal Refs #6 Co-Authored-By: Claude --- ...the-host-browser-via-the-openuri-portal.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/adr/0004-open-links-in-the-host-browser-via-the-openuri-portal.md diff --git a/docs/adr/0004-open-links-in-the-host-browser-via-the-openuri-portal.md b/docs/adr/0004-open-links-in-the-host-browser-via-the-openuri-portal.md new file mode 100644 index 0000000..7e828ff --- /dev/null +++ b/docs/adr/0004-open-links-in-the-host-browser-via-the-openuri-portal.md @@ -0,0 +1,66 @@ +# 0004. Open TPV's account-linking links in the host browser via the OpenURI portal + +- **Status:** Accepted +- **Date:** 2026-09-17 +- **Deciders:** Ben Atkinson +- **Feature / area:** host-browser-links +- **Builds on:** none — first ADR for host-browser-links +- **Supersedes / Superseded by:** none + +## What problem were we trying to solve? + +Clicking **Connect Garmin** when TPV loads did nothing: no browser and no error +(#6). The same applies to Strava, TrainingPeaks, intervals.icu and the other +account-linking buttons. TPV calls `OpenURL` with an OAuth page. Under Wine +that goes to `xdg-open`, and the container has no browser, so the request was +silently dropped. + +A key finding shaped the fix: the OAuth redirect goes to +`api.indievelo.com`, **not** a localhost callback or a custom URI scheme. The +login only has to happen in *a* browser; nothing needs to reach back into the +container or Wine. + +## What did we try? + +### Option 1: install a browser in the image ❌ considered, rejected + +It would add hundreds of MB to an already ~6.6 GB image, give a second browser +profile without the user's saved logins or password manager, and need more +display and sandbox plumbing inside the container. + +### Option 2: only print the URL for the user to open by hand ❌ considered, kept as the fallback + +It always works, but it's poor UX for a button users expect to just open a +page, and the URL only appears in container output. + +### Option 3: forward links to the host desktop over D-Bus ✅ chosen + +`org.freedesktop.portal.OpenURI` is the standard way sandboxed apps (Flatpak) +ask the desktop to open a link, and every mainstream Linux desktop provides it. +The container only needs the host session bus mounted. + +## What did we land on, and why? + +- **`scripts/xdg-open-host.sh`**, symlinked as `/usr/local/bin/xdg-open` in the + image. It accepts only `http(s)` and `mailto` links and calls + `OpenURI.OpenURI` with `gdbus` (new package `libglib2.0-bin`). If there's no + session bus or the portal refuses, it prints the link to open by hand. +- **`run-tpv.sh`** mounts `$XDG_RUNTIME_DIR/bus` as `/run/host-session-bus` + and sets `DBUS_SESSION_BUS_ADDRESS`. Without a session bus it warns that the + account-linking buttons can't open a browser. + +This won because the link opens in the user's own browser, with their existing +logins, at the cost of one small package and one mount. Because the OAuth +redirect lands on TPV's servers, no callback handling is needed. + +## What does this cost us? + +- **More host coupling:** the container now has the host **session** bus as + well as the system bus. Anything in the container can talk to session + services, so this is packaging, not sandboxing (see README Scope). +- **Only link types are filtered:** anything other than http(s)/mailto is + refused, but any http(s) URL TPV asks for will open without confirmation. +- **Headless sessions** (no session bus or portal) fall back to printing the + link. +- **Not yet verified here:** that Garmin linking completes end to end and + persists across container restarts.