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/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. 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