Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a73aaf629 |
@@ -2,9 +2,6 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on "Install", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do ("download the update again"), and offers Download again instead of a dead "Install" button, rather than a generic "it failed".
|
||||
- **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.
|
||||
|
||||
@@ -64,12 +64,6 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"must be",
|
||||
"cannot",
|
||||
"Password",
|
||||
// OTA apply/download errors are all operator-actionable ("download it
|
||||
// again", "download first") — sanitizing them to "Operation failed"
|
||||
// left users stuck with no idea what to do, and hid the "already
|
||||
// running" text the update UI matches on to join an in-flight apply
|
||||
// instead of showing a false failure. Every such message starts "Update".
|
||||
"Update",
|
||||
// The federation escalation sentinel. "Password" above does NOT cover
|
||||
// it — starts_with is case-sensitive and the sentinel is ALL-CAPS —
|
||||
// so the frontend's isPasswordRequired() never saw it and the
|
||||
|
||||
@@ -1013,7 +1013,7 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
|
||||
/// partially-corrupt resume still fails cleanly.
|
||||
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("Update already in progress — another download or apply is already running")
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
})?;
|
||||
let mut state = load_state(data_dir).await?;
|
||||
if state.available_update.is_none() {
|
||||
@@ -1406,8 +1406,8 @@ async fn verify_staged_components(staging_dir: &Path, manifest: &UpdateManifest)
|
||||
.unwrap_or(0);
|
||||
if len != component.size_bytes {
|
||||
anyhow::bail!(
|
||||
"Update staging is inconsistent: component {} is {} bytes but the manifest says {} — \
|
||||
re-download before applying (incomplete or concurrently-rewritten download)",
|
||||
"staged component {} is {} bytes but the manifest says {} — \
|
||||
refusing to apply (incomplete or concurrently-rewritten download)",
|
||||
component.name,
|
||||
len,
|
||||
component.size_bytes
|
||||
@@ -1519,11 +1519,11 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
|
||||
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("Update already in progress — another download or apply is already running")
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
})?;
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
if !staging_dir.exists() {
|
||||
anyhow::bail!("Update not staged — download it first, then apply.");
|
||||
anyhow::bail!("No staged update found. Download first.");
|
||||
}
|
||||
|
||||
// Gate 1: the completion marker is written only after EVERY component
|
||||
@@ -1531,7 +1531,7 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
// or in-flight download — exactly what got installed on .198.
|
||||
if !has_staged_update(data_dir).await {
|
||||
anyhow::bail!(
|
||||
"Update download was incomplete (no completion marker) — download the update again before applying"
|
||||
"Staged update is incomplete (no completion marker) — download the update again before applying"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1541,7 +1541,7 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
.available_update
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Update manifest missing from state — re-download the update"
|
||||
"no update manifest in state to verify staged files against — re-download the update"
|
||||
)
|
||||
})?;
|
||||
verify_staged_components(&staging_dir, &manifest).await?;
|
||||
@@ -1578,83 +1578,41 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
info!("Current binary backed up");
|
||||
}
|
||||
|
||||
// Apply staged components in a DETERMINISTIC order, binary LAST.
|
||||
// read_dir order is filesystem-arbitrary, and each component used to be
|
||||
// consumed destructively — so a mid-apply failure could leave staging
|
||||
// half-emptied and un-reappliable (Gate 2 re-verifies EVERY manifest
|
||||
// component against staging, so a missing one wedges every retry:
|
||||
// "doesn't apply, still says install, can never apply again"). Two
|
||||
// guards against that now: (a) nothing is removed from staging here —
|
||||
// the binary is copied, not moved (see its block) — so a failed apply
|
||||
// is always retryable from the same staged files; (b) the binary, the
|
||||
// one component whose swap changes what runs after restart, is applied
|
||||
// only after the frontend/runtime succeed, so a frontend failure never
|
||||
// leaves a new binary staged to run against an old frontend on the next
|
||||
// restart.
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
{
|
||||
let mut entries = fs::read_dir(&staging_dir)
|
||||
.await
|
||||
.context("Failed to read staging dir")?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
names.push(entry.file_name().to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
names.sort_by_key(|n| match n.as_str() {
|
||||
"archipelago" => 2, // binary last
|
||||
n if n.contains("runtime") && n.ends_with(".tar.gz") => 1,
|
||||
_ => 0, // frontend and everything else first
|
||||
});
|
||||
// Apply staged components
|
||||
let mut entries = fs::read_dir(&staging_dir)
|
||||
.await
|
||||
.context("Failed to read staging dir")?;
|
||||
|
||||
for name in &names {
|
||||
let name = name.as_str();
|
||||
let src = staging_dir.join(name);
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let src = entry.path();
|
||||
|
||||
match name {
|
||||
match name.as_str() {
|
||||
"archipelago" => {
|
||||
// Three constraints this block works around:
|
||||
// Two namespace gotchas this block works around:
|
||||
// 1. We're running FROM /usr/local/bin/archipelago, so
|
||||
// `install`/`cp` (O_TRUNC + write) fail with ETXTBSY.
|
||||
// rename() over a busy destination is fine.
|
||||
// Use `mv`, which is atomic rename() and tolerates a
|
||||
// busy destination.
|
||||
// 2. archipelago.service sets ProtectSystem=strict, so
|
||||
// even `sudo mv` into /usr/local/bin/ fails EROFS —
|
||||
// sudo inherits the service's mount namespace. Route
|
||||
// through host_sudo (systemd-run transient unit with
|
||||
// default protections).
|
||||
// 3. The staged binary must SURVIVE this so a later
|
||||
// component's failure leaves the apply retryable. So we
|
||||
// COPY the staged file to a sibling temp in the target
|
||||
// dir, then atomic-rename the temp over the target —
|
||||
// the staging copy is never moved. (mv'ing the staged
|
||||
// file itself was the wedging bug: binary applied, then
|
||||
// frontend fails, staging now missing the binary, every
|
||||
// retry fails re-verification forever.)
|
||||
// the rename through systemd-run so it runs in a
|
||||
// transient unit with default protections.
|
||||
let staged = src.to_string_lossy().to_string();
|
||||
let tmp = format!(
|
||||
"/usr/local/bin/.archipelago.new.{}",
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
);
|
||||
let cp = host_sudo(&["cp", "-f", &staged, &tmp])
|
||||
.await
|
||||
.with_context(|| format!("Failed to copy staged binary for {}", name))?;
|
||||
if !cp.success() {
|
||||
let _ = host_sudo(&["rm", "-f", &tmp]).await;
|
||||
anyhow::bail!("copy of staged binary failed for {}", name);
|
||||
}
|
||||
let _ = host_sudo(&["chmod", "0755", &tmp]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &tmp]).await;
|
||||
let status = host_sudo(&["mv", &tmp, "/usr/local/bin/archipelago"])
|
||||
let _ = host_sudo(&["chmod", "0755", &staged]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &staged]).await;
|
||||
let status = host_sudo(&["mv", &staged, "/usr/local/bin/archipelago"])
|
||||
.await
|
||||
.with_context(|| format!("Failed to spawn mv for {}", name))?;
|
||||
if !status.success() {
|
||||
let _ = host_sudo(&["rm", "-f", &tmp]).await;
|
||||
anyhow::bail!(
|
||||
"mv into /usr/local/bin failed for {} (exit {:?})",
|
||||
name,
|
||||
status.code()
|
||||
);
|
||||
}
|
||||
info!(name = %name, "Backend binary applied (staging preserved)");
|
||||
info!(name = %name, "Backend binary applied");
|
||||
}
|
||||
_ if name.contains("frontend") && name.ends_with(".tar.gz") => {
|
||||
// Tarball contents are the *inside* of web-ui/ (root entries
|
||||
@@ -2780,20 +2738,9 @@ mod tests {
|
||||
save_state(dir.path(), &state).await.unwrap();
|
||||
let err = apply_update(dir.path()).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("re-download before applying"),
|
||||
err.to_string().contains("refusing to apply"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
// Resilience: a refused apply must leave the update still available and
|
||||
// still staged, so the user can re-download and retry — never a wedge.
|
||||
let loaded = load_state(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
loaded.available_update.is_some(),
|
||||
"a refused apply must not clear the available update"
|
||||
);
|
||||
assert!(
|
||||
loaded.update_in_progress,
|
||||
"a refused apply must leave the staged-update flag set for retry"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -408,10 +408,6 @@ DOCKERFILE_HEAD
|
||||
xorg \
|
||||
xdotool \
|
||||
chromium \
|
||||
mesa-va-drivers \
|
||||
intel-media-va-driver \
|
||||
i965-va-driver \
|
||||
vainfo \
|
||||
pipewire \
|
||||
pipewire-pulse \
|
||||
pipewire-alsa \
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
#!/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=$!
|
||||
@@ -180,16 +157,8 @@ 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)
|
||||
@@ -225,7 +194,7 @@ while true; do
|
||||
--no-first-run \
|
||||
--check-for-update-interval=31536000 \
|
||||
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
|
||||
--enable-features=$ENABLE_FEATURES \
|
||||
--enable-features=OverlayScrollbar \
|
||||
--disable-session-crashed-bubble \
|
||||
--disable-save-password-bubble \
|
||||
--disable-suggestions-service \
|
||||
|
||||
@@ -54,7 +54,6 @@ 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
|
||||
@@ -106,10 +105,6 @@ 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>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
// 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 */ }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1087,18 +1087,7 @@ async function applyUpdate() {
|
||||
}
|
||||
return
|
||||
}
|
||||
// Surface the backend's actual, actionable message when it gave one
|
||||
// ("Update download was incomplete — download again", etc.) instead of a
|
||||
// generic dead end. The staged files are preserved across a failed apply,
|
||||
// so re-download stays available and a retry is always possible.
|
||||
const detail = errorMessage(e)
|
||||
const actionable = /^Update\b/.test(detail)
|
||||
showStatus(actionable ? detail : t('systemUpdate.applyFailed'), true)
|
||||
// A staging inconsistency means the download is the thing to redo — drop
|
||||
// the downloaded flag so the button offers Download again, not Apply.
|
||||
if (/incomplete|not staged|re-download|inconsistent/i.test(detail)) {
|
||||
downloaded.value = false
|
||||
}
|
||||
showStatus(t('systemUpdate.applyFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Apply failed', e)
|
||||
applying.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user