Compare commits

...
Author SHA1 Message Date
DorianandClaude Opus 4.7 b8397b5ecb release(v1.7.5-alpha): OTA end-to-end test bump
Trivial version-only bump. No code changes; binary differs only in its
embedded CARGO_PKG_VERSION string. Frontend tarball is byte-identical
to v1.7.4-alpha's (same sha), copied under the new filename to satisfy
the manifest component naming.

This exists so the fleet nodes (.116/.198/.253), all now running
v1.7.4-alpha with the fixed apply_update tar flow, can exercise the
full OTA pipeline from the UI: Check → Download (30-min timeout) →
Install (sudo install binary + sudo tar to web-ui.new + atomic swap) →
auto-restart (systemctl --no-block) → sidebar updates → state sync.

Artefacts:
  archipelago                                      7422a695…a1a2a6  40362432
  archipelago-frontend-1.7.5-alpha.tar.gz          4fb79664…0172e9  76984615 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:10:50 -04:00
DorianandClaude Opus 4.7 1e7df417a4 release(v1.7.4-alpha): fix Install Update tar extraction + progress overshoot
apply_update was extracting the frontend tarball with
`tar -xzf -C /opt/archipelago`, but the tar contents are the *inside*
of web-ui/ (root entries are ./test-aiui.html, ./assets/, etc.). So
the files landed directly in /opt/archipelago instead of under web-ui/,
and tar bailed on nginx-owned paths mid-extraction. First end-to-end
OTA test (.198) found it: "tar: ./assets/SystemUpdate-…js: Cannot
open: No such file or directory".

Now extracts into web-ui.new, chowns, then atomically swaps: move
existing web-ui → web-ui.bak, then web-ui.new → web-ui. Same pattern
as the manual sideload that's been working.

Frontend: SystemUpdate.vue fake download progress was capped at "<90"
with a Math.random()*15 increment — the last tick could push to
~104.99%. Capped at 95% with a smaller step so it stops at 95 and the
real RPC completion jumps it to 100.

Artefacts:
  archipelago                                      a14ad7e4…2a2be3  40361984
  archipelago-frontend-1.7.4-alpha.tar.gz          4fb79664…0172e9  76984615

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:02:14 -04:00
DorianandClaude Opus 4.7 3db71adb55 release(v1.7.3-alpha): sidebar version sync + FIPS reconnect + profile pic render
Sidebar version
  detect_build_version() no longer reads /opt/archipelago/build-info.txt
  first. That file was written by the ISO installer at flash time and
  never rewritten by OTA or sideload, so after any binary swap the
  sidebar kept advertising whatever the ISO shipped with. Now just
  returns env!("CARGO_PKG_VERSION") unconditionally — always matches the
  running binary.

FIPS card
  The two-column grid in FipsNetworkCard.vue placed version/npub boxes
  side-by-side on mobile but the anchor-status panel forced col-span-2,
  creating an unbalanced empty column at every desktop width. Anchor
  status moves to its own full-width row below the grid. When the
  anchor is not reached, a "Reconnect" button appears next to the
  status line; it calls fips.restart (45s timeout), waits 5s for the
  daemon to come back, then reloads fips.status. Surfaces whether the
  restart actually recovered the anchor in a status flash.

Profile picture render
  Uploaded profile pictures are stored with an onion-rooted URL so
  external Nostr clients can fetch them. The local browser isn't
  Tor-routed though, so the <img src> silently 404'd and the UI fell
  back to showing initials. Added a displayableUrl() helper on
  Web5Identities.vue that rewrites http://<onion>/blob/<cid>[?...] to
  same-origin /blob/<cid> for rendering, while the stored URL keeps
  its onion prefix so publishing to Nostr still works for external
  viewers. Pass-through for data: URLs and already-relative paths.

Identity row title
  The identity list header now renders profile.display_name (when set)
  and keeps identity.name as a muted parenthetical. Before, only the
  internal name was shown and a user who'd customised their Nostr
  display_name saw a mismatch between their own UI and what peers
  rendered.

Artefacts:
  archipelago                                      99184b95…22dc1b  40350664
  archipelago-frontend-1.7.3-alpha.tar.gz          7b933cf4…74a8bc  76987031

Changelog layman-style per the saved feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:44:59 -04:00
7 changed files with 128 additions and 59 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.2-alpha"
version = "1.7.5-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.2-alpha"
version = "1.7.5-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+7 -10
View File
@@ -265,16 +265,13 @@ impl DataModel {
/// falling back to Cargo.toml version. This allows sequential CI build
/// numbers to be reflected in the UI without recompiling the binary.
fn detect_build_version() -> String {
if let Ok(content) = std::fs::read_to_string("/opt/archipelago/build-info.txt") {
for line in content.lines() {
if let Some(v) = line.strip_prefix("version=") {
let v = v.trim();
if !v.is_empty() {
return v.to_string();
}
}
}
}
// Always use the binary's compiled-in version. The ISO installer
// writes /opt/archipelago/build-info.txt at install time, but that
// file is never rewritten by OTA or sideload, so trusting it made
// the sidebar permanently advertise whatever the ISO shipped with
// even after the running binary had moved on. CARGO_PKG_VERSION is
// baked into the binary at compile time, so it always matches what
// is actually running.
env!("CARGO_PKG_VERSION").to_string()
}
+43 -25
View File
@@ -305,42 +305,60 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
info!(name = %name, "Backend binary applied");
}
_ if name.contains("frontend") && name.ends_with(".tar.gz") => {
let web_ui_dir = Path::new("/opt/archipelago/web-ui");
// Back up current frontend. /opt/archipelago is root-owned;
// the backup goes under our data_dir where we can write.
let frontend_backup = backup_dir.join("web-ui-backup.tar.gz");
if web_ui_dir.exists() {
let status = tokio::process::Command::new("sudo")
.args([
"tar",
"-czf",
&frontend_backup.to_string_lossy(),
"-C",
"/opt/archipelago",
"web-ui",
])
.status()
.await
.context("Failed to backup frontend")?;
if status.success() {
info!("Frontend backed up");
}
// The tarball contents are the *inside* of web-ui/ — root
// entries are `./test-aiui.html`, `./assets/`, etc. Extract
// into a sibling staging dir, then swap atomically so
// nginx never sees a half-written tree.
let new_dir = "/opt/archipelago/web-ui.new";
let backup_path = "/opt/archipelago/web-ui.bak";
// Wipe any previous attempt's staging / backup dirs.
let _ = tokio::process::Command::new("sudo")
.args(["rm", "-rf", new_dir, backup_path])
.status()
.await;
let mk = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", new_dir])
.status()
.await
.context("Failed to create frontend staging dir")?;
if !mk.success() {
anyhow::bail!("mkdir {} failed", new_dir);
}
// Extract new frontend into /opt/archipelago (root-owned dir).
// Extract INTO the staging dir — tar's ./ entries land at
// the right place (web-ui.new/assets/... etc.).
let status = tokio::process::Command::new("sudo")
.args(["tar", "-xzf", &src.to_string_lossy(), "-C", "/opt/archipelago"])
.args(["tar", "-xzf", &src.to_string_lossy(), "-C", new_dir])
.status()
.await
.with_context(|| format!("Failed to extract {}", name))?;
if !status.success() {
anyhow::bail!("tar extraction failed for {}", name);
}
// nginx serves this tree; keep ownership consistent with
// what first-boot + the ISO layout expect.
// Ownership: match what first-boot + the ISO expect.
let _ = tokio::process::Command::new("sudo")
.args(["chown", "-R", "archipelago:archipelago", "/opt/archipelago/web-ui"])
.args(["chown", "-R", "archipelago:archipelago", new_dir])
.status()
.await;
// Atomic-ish swap: move old aside, new into place.
let web_ui = "/opt/archipelago/web-ui";
if Path::new(web_ui).exists() {
let mv_old = tokio::process::Command::new("sudo")
.args(["mv", web_ui, backup_path])
.status()
.await
.context("Failed to rotate old web-ui")?;
if !mv_old.success() {
anyhow::bail!("failed to move old web-ui aside");
}
}
let mv_new = tokio::process::Command::new("sudo")
.args(["mv", new_dir, web_ui])
.status()
.await
.context("Failed to swap new web-ui into place")?;
if !mv_new.success() {
anyhow::bail!("failed to move new web-ui into place");
}
info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui");
}
_ => {
+5 -3
View File
@@ -345,10 +345,12 @@ async function downloadUpdate() {
downloadPercent.value = 0
statusMessage.value = ''
// Simulate incremental progress while waiting for the RPC
// Simulate incremental progress while waiting for the RPC. Capped at
// 95% so the bar never shows >100% before the real completion jumps it
// to 100 — previously the random increment could overshoot.
const progressInterval = setInterval(() => {
if (downloadPercent.value < 90) {
downloadPercent.value += Math.random() * 15
if (downloadPercent.value < 95) {
downloadPercent.value = Math.min(95, downloadPercent.value + Math.random() * 3)
}
}, 500)
+53 -16
View File
@@ -18,27 +18,12 @@
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3 flex-1 min-h-0">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3 shrink-0">
<div class="p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Daemon version</p>
<p class="text-sm font-medium text-white break-all">{{ status.version || '—' }}</p>
<p v-if="!status.installed" class="text-xs text-white/40 mt-1">Package not installed</p>
</div>
<div v-if="status.service_active" class="p-3 bg-white/5 rounded-lg sm:col-span-2">
<div class="flex items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :class="status.anchor_connected ? 'bg-cyan-400' : 'bg-orange-400'"></span>
<span class="text-white/70">Anchor (fips.v0l.io):</span>
<span :class="status.anchor_connected ? 'text-cyan-300' : 'text-orange-300'">
{{ status.anchor_connected ? 'connected' : 'not reached' }}
</span>
</div>
<div class="text-white/60">{{ status.authenticated_peer_count ?? 0 }} peer{{ (status.authenticated_peer_count ?? 0) === 1 ? '' : 's' }}</div>
</div>
<p v-if="!status.anchor_connected" class="mt-1 text-[11px] text-white/40">
Without the anchor, DHT routing to unknown npubs can't bootstrap; federation + messaging will fall back to Tor until it reconnects.
</p>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between gap-2 mb-1">
<p class="text-xs text-white/60">FIPS npub</p>
@@ -60,6 +45,33 @@
</div>
</div>
<!-- Anchor status: always full-width to keep desktop layout tidy -->
<div v-if="status.service_active" class="p-3 bg-white/5 rounded-lg mb-3 shrink-0">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-2 text-xs">
<span class="w-2 h-2 rounded-full" :class="status.anchor_connected ? 'bg-cyan-400' : 'bg-orange-400'"></span>
<span class="text-white/70">Anchor (fips.v0l.io):</span>
<span :class="status.anchor_connected ? 'text-cyan-300' : 'text-orange-300'">
{{ status.anchor_connected ? 'connected' : 'not reached' }}
</span>
<span class="text-white/40">·</span>
<span class="text-white/60">{{ status.authenticated_peer_count ?? 0 }} peer{{ (status.authenticated_peer_count ?? 0) === 1 ? '' : 's' }}</span>
</div>
<button
v-if="!status.anchor_connected"
type="button"
class="text-xs px-3 py-1.5 rounded-md bg-orange-400/15 hover:bg-orange-400/25 text-orange-200 disabled:opacity-60 transition-colors"
:disabled="reconnecting"
@click="reconnectAnchor"
>
{{ reconnecting ? 'Reconnecting…' : 'Reconnect' }}
</button>
</div>
<p v-if="!status.anchor_connected" class="mt-2 text-[11px] text-white/40 leading-snug">
Without the anchor, DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until it reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache.
</p>
</div>
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
<div v-if="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0">
@@ -97,6 +109,7 @@ const status = ref<FipsStatus>({
anchor_connected: false,
})
const installing = ref(false)
const reconnecting = ref(false)
const statusMessage = ref('')
const statusIsError = ref(false)
const copied = ref(false)
@@ -167,5 +180,29 @@ async function installAndActivate() {
}
}
// Restart the FIPS daemon to kick it back onto the public anchor. Stale
// identity-cache entries are the usual cause of "not reached"; systemctl
// restart clears them and re-runs the bootstrap handshake.
async function reconnectAnchor() {
reconnecting.value = true
try {
await rpcClient.call({ method: 'fips.restart', timeout: 45_000 })
// Give the daemon a few seconds to come back and re-populate its
// identity cache before we re-query status.
await new Promise((resolve) => setTimeout(resolve, 5000))
await loadStatus()
if (status.value.anchor_connected) {
flash('Anchor reconnected')
} else {
flash('FIPS restarted — anchor still reporting unreachable. Check network / firewall.', true)
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Reconnect failed: ${msg}`, true)
} finally {
reconnecting.value = false
}
}
onMounted(loadStatus)
</script>
+18 -3
View File
@@ -68,7 +68,7 @@
>
<!-- Avatar -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
<img v-if="identity.profile?.picture" :src="identity.profile.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<img v-if="identity.profile?.picture" :src="displayableUrl(identity.profile.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
@@ -88,7 +88,8 @@
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-medium text-sm">{{ identity.name }}</span>
<span class="text-white font-medium text-sm">{{ identity.profile?.display_name || identity.name }}</span>
<span v-if="identity.profile?.display_name && identity.profile.display_name !== identity.name" class="text-white/40 text-xs truncate max-w-[160px]" :title="`Internal name: ${identity.name}`">({{ identity.name }})</span>
<span v-if="identity.is_default" class="text-yellow-400 text-xs" title="Default identity">&#9733;</span>
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
'bg-blue-500/20 text-blue-300': identity.purpose === 'personal',
@@ -301,7 +302,7 @@
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<div class="flex items-center gap-3 mb-5">
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
<img v-if="profileForm.picture" :src="profileForm.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<img v-if="profileForm.picture" :src="displayableUrl(profileForm.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-else class="w-full h-full flex items-center justify-center">
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
</div>
@@ -408,6 +409,20 @@ const profilePublishing = ref(false)
const avatarUploading = ref(false)
const bannerUploading = ref(false)
// The backend returns onion-based public URLs for uploaded profile
// pictures (so they're fetchable by external Nostr clients), but the
// local browser session isn't Tor-routed and can't resolve .onion hosts.
// Rewrite onion-rooted `/blob/<cid>` URLs (with or without capability
// query) to same-origin `/blob/<cid>` so they render in this UI. Data
// URLs and plain external URLs pass through untouched.
function displayableUrl(url: string | null | undefined): string {
if (!url) return ''
if (url.startsWith('data:') || url.startsWith('/')) return url
const onionMatch = url.match(/^https?:\/\/[a-z2-7]{16,56}\.onion(\/blob\/[0-9a-f]{64})(\?.*)?$/i)
if (onionMatch && onionMatch[1]) return onionMatch[1]
return url
}
// Upload to the node's blob store and drop the returned public URL into
// the profile field. The /api/blob endpoint marks these blobs public, so
// the URL served back (`public_url`, onion-rooted when Tor is up) is