Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b67e1527a2 |
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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.
|
- **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 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.
|
- **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,6 +64,12 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
|||||||
"must be",
|
"must be",
|
||||||
"cannot",
|
"cannot",
|
||||||
"Password",
|
"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
|
// The federation escalation sentinel. "Password" above does NOT cover
|
||||||
// it — starts_with is case-sensitive and the sentinel is ALL-CAPS —
|
// it — starts_with is case-sensitive and the sentinel is ALL-CAPS —
|
||||||
// so the frontend's isPasswordRequired() never saw it and the
|
// 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.
|
/// partially-corrupt resume still fails cleanly.
|
||||||
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
anyhow::anyhow!("Update already in progress — another download or apply is already running")
|
||||||
})?;
|
})?;
|
||||||
let mut state = load_state(data_dir).await?;
|
let mut state = load_state(data_dir).await?;
|
||||||
if state.available_update.is_none() {
|
if state.available_update.is_none() {
|
||||||
@@ -1406,8 +1406,8 @@ async fn verify_staged_components(staging_dir: &Path, manifest: &UpdateManifest)
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
if len != component.size_bytes {
|
if len != component.size_bytes {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"staged component {} is {} bytes but the manifest says {} — \
|
"Update staging is inconsistent: component {} is {} bytes but the manifest says {} — \
|
||||||
refusing to apply (incomplete or concurrently-rewritten download)",
|
re-download before applying (incomplete or concurrently-rewritten download)",
|
||||||
component.name,
|
component.name,
|
||||||
len,
|
len,
|
||||||
component.size_bytes
|
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.
|
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||||
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
anyhow::anyhow!("Update already in progress — another download or apply is already running")
|
||||||
})?;
|
})?;
|
||||||
let staging_dir = data_dir.join("update-staging");
|
let staging_dir = data_dir.join("update-staging");
|
||||||
if !staging_dir.exists() {
|
if !staging_dir.exists() {
|
||||||
anyhow::bail!("No staged update found. Download first.");
|
anyhow::bail!("Update not staged — download it first, then apply.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gate 1: the completion marker is written only after EVERY component
|
// 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.
|
// or in-flight download — exactly what got installed on .198.
|
||||||
if !has_staged_update(data_dir).await {
|
if !has_staged_update(data_dir).await {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Staged update is incomplete (no completion marker) — download the update again before applying"
|
"Update download was 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
|
.available_update
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"no update manifest in state to verify staged files against — re-download the update"
|
"Update manifest missing from state — re-download the update"
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
verify_staged_components(&staging_dir, &manifest).await?;
|
verify_staged_components(&staging_dir, &manifest).await?;
|
||||||
@@ -1578,41 +1578,83 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
|||||||
info!("Current binary backed up");
|
info!("Current binary backed up");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply staged components
|
// Apply staged components in a DETERMINISTIC order, binary LAST.
|
||||||
let mut entries = fs::read_dir(&staging_dir)
|
// read_dir order is filesystem-arbitrary, and each component used to be
|
||||||
.await
|
// consumed destructively — so a mid-apply failure could leave staging
|
||||||
.context("Failed to read staging dir")?;
|
// 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
|
||||||
|
});
|
||||||
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
for name in &names {
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = name.as_str();
|
||||||
let src = entry.path();
|
let src = staging_dir.join(name);
|
||||||
|
|
||||||
match name.as_str() {
|
match name {
|
||||||
"archipelago" => {
|
"archipelago" => {
|
||||||
// Two namespace gotchas this block works around:
|
// Three constraints this block works around:
|
||||||
// 1. We're running FROM /usr/local/bin/archipelago, so
|
// 1. We're running FROM /usr/local/bin/archipelago, so
|
||||||
// `install`/`cp` (O_TRUNC + write) fail with ETXTBSY.
|
// `install`/`cp` (O_TRUNC + write) fail with ETXTBSY.
|
||||||
// Use `mv`, which is atomic rename() and tolerates a
|
// rename() over a busy destination is fine.
|
||||||
// busy destination.
|
|
||||||
// 2. archipelago.service sets ProtectSystem=strict, so
|
// 2. archipelago.service sets ProtectSystem=strict, so
|
||||||
// even `sudo mv` into /usr/local/bin/ fails EROFS —
|
// even `sudo mv` into /usr/local/bin/ fails EROFS —
|
||||||
// sudo inherits the service's mount namespace. Route
|
// sudo inherits the service's mount namespace. Route
|
||||||
// the rename through systemd-run so it runs in a
|
// through host_sudo (systemd-run transient unit with
|
||||||
// transient unit with default protections.
|
// 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.)
|
||||||
let staged = src.to_string_lossy().to_string();
|
let staged = src.to_string_lossy().to_string();
|
||||||
let _ = host_sudo(&["chmod", "0755", &staged]).await;
|
let tmp = format!(
|
||||||
let _ = host_sudo(&["chown", "root:root", &staged]).await;
|
"/usr/local/bin/.archipelago.new.{}",
|
||||||
let status = host_sudo(&["mv", &staged, "/usr/local/bin/archipelago"])
|
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"])
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("Failed to spawn mv for {}", name))?;
|
.with_context(|| format!("Failed to spawn mv for {}", name))?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
|
let _ = host_sudo(&["rm", "-f", &tmp]).await;
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"mv into /usr/local/bin failed for {} (exit {:?})",
|
"mv into /usr/local/bin failed for {} (exit {:?})",
|
||||||
name,
|
name,
|
||||||
status.code()
|
status.code()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
info!(name = %name, "Backend binary applied");
|
info!(name = %name, "Backend binary applied (staging preserved)");
|
||||||
}
|
}
|
||||||
_ if name.contains("frontend") && name.ends_with(".tar.gz") => {
|
_ if name.contains("frontend") && name.ends_with(".tar.gz") => {
|
||||||
// Tarball contents are the *inside* of web-ui/ (root entries
|
// Tarball contents are the *inside* of web-ui/ (root entries
|
||||||
@@ -2738,9 +2780,20 @@ mod tests {
|
|||||||
save_state(dir.path(), &state).await.unwrap();
|
save_state(dir.path(), &state).await.unwrap();
|
||||||
let err = apply_update(dir.path()).await.unwrap_err();
|
let err = apply_update(dir.path()).await.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
err.to_string().contains("refusing to apply"),
|
err.to_string().contains("re-download before applying"),
|
||||||
"got: {err:#}"
|
"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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -408,6 +408,10 @@ DOCKERFILE_HEAD
|
|||||||
xorg \
|
xorg \
|
||||||
xdotool \
|
xdotool \
|
||||||
chromium \
|
chromium \
|
||||||
|
mesa-va-drivers \
|
||||||
|
intel-media-va-driver \
|
||||||
|
i965-va-driver \
|
||||||
|
vainfo \
|
||||||
pipewire \
|
pipewire \
|
||||||
pipewire-pulse \
|
pipewire-pulse \
|
||||||
pipewire-alsa \
|
pipewire-alsa \
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
#!/bin/bash
|
#!/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.
|
# Start a dedicated X server for the attached kiosk display.
|
||||||
/usr/bin/Xorg :0 vt1 -nolisten tcp -keeptty &
|
/usr/bin/Xorg :0 vt1 -nolisten tcp -keeptty &
|
||||||
XPID=$!
|
XPID=$!
|
||||||
@@ -157,8 +180,16 @@ sleep 1
|
|||||||
# On a GPU-less / headless server (no /dev/dri), disable GPU entirely instead.
|
# On a GPU-less / headless server (no /dev/dri), disable GPU entirely instead.
|
||||||
if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then
|
if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then
|
||||||
GPU_FLAGS="--in-process-gpu --num-raster-threads=1"
|
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
|
else
|
||||||
GPU_FLAGS="--disable-gpu --num-raster-threads=1"
|
GPU_FLAGS="--disable-gpu --num-raster-threads=1"
|
||||||
|
ENABLE_FEATURES="OverlayScrollbar"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ARCHIPELAGO_UID=$(id -u archipelago)
|
ARCHIPELAGO_UID=$(id -u archipelago)
|
||||||
@@ -194,7 +225,7 @@ while true; do
|
|||||||
--no-first-run \
|
--no-first-run \
|
||||||
--check-for-update-interval=31536000 \
|
--check-for-update-interval=31536000 \
|
||||||
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
|
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
|
||||||
--enable-features=OverlayScrollbar \
|
--enable-features=$ENABLE_FEATURES \
|
||||||
--disable-session-crashed-bubble \
|
--disable-session-crashed-bubble \
|
||||||
--disable-save-password-bubble \
|
--disable-save-password-bubble \
|
||||||
--disable-suggestions-service \
|
--disable-suggestions-service \
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { ref, computed, watch } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||||
|
import { useModalHistory } from '@/composables/useModalHistory'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
@@ -105,6 +106,10 @@ function close() {
|
|||||||
|
|
||||||
useModalKeyboard(modalRef, computed(() => props.show), close)
|
useModalKeyboard(modalRef, computed(() => props.show), close)
|
||||||
useBodyScrollLock(computed(() => props.show))
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1087,7 +1087,18 @@ async function applyUpdate() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showStatus(t('systemUpdate.applyFailed'), true)
|
// 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
|
||||||
|
}
|
||||||
if (import.meta.env.DEV) console.warn('Apply failed', e)
|
if (import.meta.env.DEV) console.warn('Apply failed', e)
|
||||||
applying.value = false
|
applying.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user