Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Containerfile.full
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 \
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions run-tpv.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}" \
Expand Down
30 changes: 30 additions & 0 deletions scripts/xdg-open-host.sh
Original file line number Diff line number Diff line change
@@ -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 <url>" >&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