Compare commits

...
Author SHA1 Message Date
archipelagoandClaude Fable 5 7ddba0b974 fix(kiosk): vsynced video + native-app Back behaviour
Demo images / Build & push demo images (push) Successful in 4m50s
Tearing: bare Xorg had no vsync and no compositor — flips landed
mid-scanout on every kiosk. The launcher now writes a modesetting TearFree
xorg drop-in before starting X (reaches existing kiosks via bootstrap's
launcher reinstall), enables VA-API decode on GPU hardware (offloads the
CPU — pushes WITH the 2026-06-28 choppy-audio fix, not against it; silent
software fallback), and the rootfs package list gains the va drivers.

Back/forward: modals lived outside browser history, so Back navigated the
router underneath open dialogs. useModalHistory in BaseModal pushes one
entry per open modal: Back closes the topmost dialog first, UI-close
consumes its own entry, depth markers protect router entries, vue-router
state keys preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 16:16:44 -04:00
lfg2025 3d7c44e3b2 fix(companion): version note matches the shipped APK (#138)
Demo images / Build & push demo images (push) Successful in 4m32s
2026-08-11 16:54:12 +00:00
5 changed files with 125 additions and 1 deletions
+2
View File
@@ -2,6 +2,8 @@
## Unreleased
- **Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion — IndeedHub films especially — showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware — smoother playback that also leaves more headroom for audio, not less.
- **The Back button finally does what you expect.** Pressing Back — the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser — used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.
- **No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice — once by its proper name, once as a raw `http://146…` address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin — which was always the same machine.
- **The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.
- **The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own — with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast.
@@ -408,6 +408,10 @@ DOCKERFILE_HEAD
xorg \
xdotool \
chromium \
mesa-va-drivers \
intel-media-va-driver \
i965-va-driver \
vainfo \
pipewire \
pipewire-pulse \
pipewire-alsa \
@@ -1,5 +1,28 @@
#!/bin/bash
# TearFree BEFORE X starts: bare Xorg with the stock modesetting driver has
# no vsync and no compositor, so video page-flips land mid-scanout — visible
# tearing on every kiosk (reported 2026-08-11, "really bad" on IndeedHub
# playback). The modesetting driver's TearFree option double-buffers the
# scanout at the driver level: no compositor needed, one frame of latency,
# no interaction with the 2026-06-28 choppy-audio GPU decisions. Written
# here (idempotently) rather than baked into the image so existing kiosk
# nodes pick it up through the launcher's own OTA path (bootstrap.rs
# reinstalls this script on every node).
XORG_CONF_DIR=/etc/X11/xorg.conf.d
XORG_TEARFREE="$XORG_CONF_DIR/20-archipelago-kiosk-tearfree.conf"
mkdir -p "$XORG_CONF_DIR"
if [ ! -f "$XORG_TEARFREE" ] || ! grep -q TearFree "$XORG_TEARFREE"; then
cat > "$XORG_TEARFREE" <<'EOF'
# Written by archipelago-kiosk-launcher — vsynced scanout for kiosk video.
Section "Device"
Identifier "Archipelago Kiosk GPU"
Driver "modesetting"
Option "TearFree" "true"
EndSection
EOF
fi
# Start a dedicated X server for the attached kiosk display.
/usr/bin/Xorg :0 vt1 -nolisten tcp -keeptty &
XPID=$!
@@ -157,8 +180,16 @@ sleep 1
# On a GPU-less / headless server (no /dev/dri), disable GPU entirely instead.
if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then
GPU_FLAGS="--in-process-gpu --num-raster-threads=1"
# Hardware VIDEO DECODE (VA-API) on GPU hardware. Orthogonal to the
# GpuRasterization ban above: decode offload REDUCES the CPU pressure
# that caused the choppy-audio incident, it doesn't re-create it.
# Falls back silently to software decode when the platform lacks a
# va driver — never a black player. IgnoreDriverChecks: older Intel
# gens (HD 5500-era kiosks) are wrongly blocklisted upstream.
ENABLE_FEATURES="OverlayScrollbar,VaapiVideoDecodeLinuxGL,VaapiIgnoreDriverChecks"
else
GPU_FLAGS="--disable-gpu --num-raster-threads=1"
ENABLE_FEATURES="OverlayScrollbar"
fi
ARCHIPELAGO_UID=$(id -u archipelago)
@@ -194,7 +225,7 @@ while true; do
--no-first-run \
--check-for-update-interval=31536000 \
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
--enable-features=OverlayScrollbar \
--enable-features=$ENABLE_FEATURES \
--disable-session-crashed-bubble \
--disable-save-password-bubble \
--disable-suggestions-service \
+5
View File
@@ -54,6 +54,7 @@ import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
import { useModalHistory } from '@/composables/useModalHistory'
const props = withDefaults(defineProps<{
show: boolean
@@ -105,6 +106,10 @@ function close() {
useModalKeyboard(modalRef, computed(() => props.show), close)
useBodyScrollLock(computed(() => props.show))
// Browser/mouse/gesture Back closes the modal instead of navigating the
// router out from under it the native-app behaviour kiosk and mobile
// browsers expect (the companion webview already provides it natively).
useModalHistory(computed(() => props.show), close)
</script>
<style scoped>
@@ -0,0 +1,82 @@
// Back/forward integration for modals (kiosk, remote browsers, mobile).
//
// Without this, the browser's Back control (mouse side-button on kiosk,
// gesture on mobile, toolbar button in a remote browser) navigates the
// ROUTER while a modal is open — at best closing the whole screen under a
// dialog, at worst leaving the app. The native-app expectation, and what
// the companion webview already provides, is: Back closes the topmost
// dialog first.
//
// Mechanics: opening a modal pushes one history entry (same URL, a depth
// marker in state — router keys are preserved by spreading the existing
// state). A popstate that lands BELOW our depth means the user pressed
// Back over an open modal: close the topmost one. A UI-side close (X,
// backdrop, Esc) consumes its own entry with history.back() so Back never
// needs pressing twice — guarded by the depth marker so it can never eat
// a router entry. One module-level stack serves every BaseModal instance,
// so stacked modals close one per Back, top first.
import { watch, type Ref } from 'vue'
type Entry = { close: () => void }
const stack: Entry[] = []
// Set when a popstate initiated the close: the history entry is already
// gone, so the close-side cleanup must not call history.back() again.
let poppedClose = false
let listening = false
function modalDepth(state: unknown): number {
return (state as { __archyModal?: number } | null)?.__archyModal ?? 0
}
function ensureListener() {
if (listening || typeof window === 'undefined') return
listening = true
window.addEventListener('popstate', (e) => {
// Landed at a depth below the open-modal count → this Back was aimed
// at the topmost modal. One entry per Back press: close exactly one.
// (A popstate at or above our depth is someone else's navigation —
// e.g. our own cleanup back, or a forward — leave it alone.)
if (modalDepth(e.state) < stack.length) {
const top = stack[stack.length - 1]
if (top) {
poppedClose = true
top.close()
}
}
})
}
/** Call from a modal component with its visibility and close trigger. */
export function useModalHistory(show: Ref<boolean>, close: () => void) {
ensureListener()
const entry: Entry = { close }
watch(show, (open, was) => {
if (open === was) return
if (open) {
stack.push(entry)
try {
// Preserve vue-router's own keys in state — clobbering them breaks
// its scroll restoration and position tracking.
window.history.pushState(
{ ...(window.history.state ?? {}), __archyModal: stack.length },
'',
)
} catch { /* history can throw in exotic embeds — modal still works */ }
} else {
const wasTop = stack[stack.length - 1] === entry
const i = stack.indexOf(entry)
if (i >= 0) stack.splice(i, 1)
if (poppedClose) {
poppedClose = false
return
}
// UI-side close of the top modal: consume the entry we pushed, but
// only if it is still the current one (a route change after opening
// moves history past it — backing out then would eat a real entry).
if (wasTop && modalDepth(window.history.state) > stack.length) {
try { window.history.back() } catch { /* same guard as above */ }
}
}
})
}