Compare commits

..

2 Commits

Author SHA1 Message Date
Dorian
3a479e5b09 release(v1.7.3-alpha): sidebar version sync + FIPS reconnect + profile pic render
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
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
Dorian
0d5128a121 release(v1.7.2-alpha): fix Install Update + identity avatar backfill + label
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
Three user-visible fixes shipped together.

1. update.apply permission-denied
   apply_update() was doing fs::copy into /usr/local/bin/archipelago and
   tar xzf into /opt/archipelago as the archipelago user — both root-owned.
   The backup step succeeded (it wrote to data_dir) but the swap failed
   with a silent permission denied, wrapped as "Failed to apply archipelago".
   Now uses `sudo install -m 0755` for the binary and `sudo tar -xzf` for
   the frontend, plus a post-apply `sudo systemctl --no-block restart
   archipelago` scheduled 2s after the RPC reply so the UI sees success.

2. Apply → Install label
   en/es locale strings: applyUpdate / applyTitle / applyNow changed from
   "Apply" to "Install". Matches the user's mental model and distinguishes
   the user-facing verb from the internal apply_update() function.

3. Identity avatar backfill
   Identities created before df83163f had profile=None on disk and so
   rendered as initials. load_record() now synthesizes an IdentityProfile
   with a default picture (identicon for regular identities, the hex node
   SVG for derivation_index=0) when profile is missing. The synthetic
   profile lives only in the returned record; the file stays untouched so
   a later explicit Save persists whatever the user actually chose.

Artefacts:
  archipelago                                        70e5444e…67c589  40381960
  archipelago-frontend-1.7.2-alpha.tar.gz            806b027b…358a824 76983699

Changelog rewritten layman-style per saved feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:25:10 -04:00
14 changed files with 167 additions and 65 deletions

2
core/Cargo.lock generated
View File

@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.1-alpha"
version = "1.7.3-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.1-alpha"
version = "1.7.3-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]

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()
}

View File

@@ -739,6 +739,20 @@ impl IdentityManager {
.and_then(|pk| pk.to_bech32().ok())
});
// Backfill a default avatar for identities created before the
// default-avatar feature shipped. The synthetic profile lives only
// in the returned record — we don't rewrite the file on disk,
// since a later explicit save will persist whatever the user
// actually chose. Master identities (seed index 0) get the hex
// node SVG; all other pre-existing identities get the identicon.
let profile = file.profile.or_else(|| {
let is_master = file.derivation_index == Some(0);
Some(IdentityProfile {
picture: Some(crate::avatar::default_picture(&file.pubkey_hex, is_master)),
..Default::default()
})
});
Ok(IdentityRecord {
id: file.id,
name: file.name,
@@ -749,7 +763,7 @@ impl IdentityManager {
created_at: file.created_at,
nostr_pubkey: file.nostr_pubkey_hex,
nostr_npub,
profile: file.profile,
profile,
})
}

View File

@@ -277,25 +277,42 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
match name.as_str() {
"archipelago" => {
let dest = Path::new("/usr/local/bin/archipelago");
fs::copy(&src, dest)
// /usr/local/bin is root-owned; archipelago user can't
// fs::copy into it directly. Use sudo install which handles
// the copy, mode, and ownership atomically.
let status = tokio::process::Command::new("sudo")
.args([
"install",
"-m",
"0755",
"-o",
"root",
"-g",
"root",
&src.to_string_lossy(),
"/usr/local/bin/archipelago",
])
.status()
.await
.with_context(|| format!("Failed to apply {}", name))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))
.context("Failed to set binary permissions")?;
.with_context(|| format!("Failed to spawn install for {}", name))?;
if !status.success() {
anyhow::bail!(
"sudo install failed for {} (exit {:?})",
name,
status.code()
);
}
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
// 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("tar")
let status = tokio::process::Command::new("sudo")
.args([
"tar",
"-czf",
&frontend_backup.to_string_lossy(),
"-C",
@@ -309,15 +326,21 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
info!("Frontend backed up");
}
}
// Extract new frontend
let status = tokio::process::Command::new("tar")
.args(["-xzf", &src.to_string_lossy(), "-C", "/opt/archipelago"])
// Extract new frontend into /opt/archipelago (root-owned dir).
let status = tokio::process::Command::new("sudo")
.args(["tar", "-xzf", &src.to_string_lossy(), "-C", "/opt/archipelago"])
.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.
let _ = tokio::process::Command::new("sudo")
.args(["chown", "-R", "archipelago:archipelago", "/opt/archipelago/web-ui"])
.status()
.await;
info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui");
}
_ => {
@@ -339,7 +362,20 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
// Clean staging
let _ = fs::remove_dir_all(&staging_dir).await;
info!("Update applied. Restart service to take effect.");
info!("Update applied — scheduling service restart in 2s so the RPC reply lands first");
// Restart asynchronously so the JSON-RPC response actually reaches the
// UI before systemd kills us. --no-block makes sure systemctl doesn't
// try to wait for the current service (us) to exit cleanly before
// starting the new process — it would deadlock otherwise.
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = tokio::process::Command::new("sudo")
.args(["systemctl", "--no-block", "restart", "archipelago"])
.status()
.await;
});
Ok(())
}

View File

@@ -663,18 +663,18 @@
"autoApply": "Auto-Apply",
"autoApplyDesc": "Check daily and automatically install updates at 3 AM. Service restarts as needed.",
"downloadUpdate": "Download Update",
"applyUpdate": "Apply Update",
"applyUpdate": "Install Update",
"checkForUpdates": "Check for Updates",
"checking": "Checking...",
"rollback": "Rollback to Previous",
"backToSettings": "Back to Settings",
"percentComplete": "{percent}% complete",
"applyWarning": "Installing components and restarting services. Do not power off.",
"applyTitle": "Apply Update?",
"applyTitle": "Install Update?",
"applyMessage": "The backend service will restart. This may take a moment.",
"rollbackTitle": "Rollback Version?",
"rollbackMessage": "This will restore the previous version. The backend service will restart.",
"applyNow": "Apply Now",
"applyNow": "Install Now",
"rollbackButton": "Rollback",
"upToDateMessage": "Your system is up to date. No updates available. Your system is running the latest version.",
"checkFailed": "Failed to check for updates. Check your internet connection.",

View File

@@ -662,18 +662,18 @@
"autoApply": "Aplicaci\u00f3n autom\u00e1tica",
"autoApplyDesc": "Buscar diariamente y aplicar actualizaciones autom\u00e1ticamente a las 3 AM. Los servicios se reinician seg\u00fan sea necesario.",
"downloadUpdate": "Descargar actualizaci\u00f3n",
"applyUpdate": "Aplicar actualizaci\u00f3n",
"applyUpdate": "Instalar actualizaci\u00f3n",
"checkForUpdates": "Buscar actualizaciones",
"checking": "Verificando...",
"rollback": "Revertir a la versi\u00f3n anterior",
"backToSettings": "Volver a configuraci\u00f3n",
"percentComplete": "{percent}% completado",
"applyWarning": "Instalando componentes y reiniciando servicios. No apague el equipo.",
"applyTitle": "\u00bfAplicar actualizaci\u00f3n?",
"applyTitle": "\u00bfInstalar actualizaci\u00f3n?",
"applyMessage": "El servicio del backend se reiniciar\u00e1. Esto puede tomar un momento.",
"rollbackTitle": "\u00bfRevertir versi\u00f3n?",
"rollbackMessage": "Esto restaurar\u00e1 la versi\u00f3n anterior. El servicio del backend se reiniciar\u00e1.",
"applyNow": "Aplicar ahora",
"applyNow": "Instalar ahora",
"rollbackButton": "Revertir",
"upToDateMessage": "Su sistema est\u00e1 actualizado. No hay actualizaciones disponibles. Su sistema est\u00e1 ejecutando la \u00faltima versi\u00f3n.",
"checkFailed": "Error al buscar actualizaciones. Verifique su conexi\u00f3n a internet.",

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>

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

View File

@@ -1,25 +1,28 @@
{
"version": "1.7.1-alpha",
"version": "1.7.3-alpha",
"release_date": "2026-04-20",
"changelog": [
"Over-the-air update test — same features as 1.7.0, just a fresh version number so your node can try the new download-and-apply flow end-to-end. Safe to apply; nothing to do afterwards."
"The version number in the sidebar now always matches the actual running version — no more lying to you about being on an older release after an update.",
"FIPS Mesh card on the server page: cleaner layout on desktop (no more awkward gaps), and a one-click Reconnect button when the public anchor is unreachable — it restarts the FIPS daemon so it can re-bootstrap from the anchor.",
"Profile pictures now show correctly in the identity list and editor. Before, uploaded images silently failed to render because the URL was only reachable over Tor; the UI now rewrites them to a local path while keeping the external URL for other Nostr clients.",
"Identity rows now show your Display Name first (from your Nostr profile) with the internal identity name beside it in parentheses, so you see the name other people will see — not just the one you picked when creating it."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.0-alpha",
"new_version": "1.7.1-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.1-alpha/archipelago",
"sha256": "7f7981bdf33af6fdb0022338c62e0a102b17c1da95f87f630b07fc2b6056eef0",
"size_bytes": 40391760
"current_version": "1.7.2-alpha",
"new_version": "1.7.3-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.3-alpha/archipelago",
"sha256": "99184b95f8d3041b7714ec9e58a194e466fa470c117992a4715c40980022dc1b",
"size_bytes": 40350664
},
{
"name": "archipelago-frontend-1.7.1-alpha.tar.gz",
"current_version": "1.7.0-alpha",
"new_version": "1.7.1-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.1-alpha/archipelago-frontend-1.7.1-alpha.tar.gz",
"sha256": "dc3b63afedc45a663a023702ea23b6ac499d5b2731078a9b5a2feb57ae9a8370",
"size_bytes": 76984288
"name": "archipelago-frontend-1.7.3-alpha.tar.gz",
"current_version": "1.7.2-alpha",
"new_version": "1.7.3-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.3-alpha/archipelago-frontend-1.7.3-alpha.tar.gz",
"sha256": "7b933cf458754faba18224d12b4793d7e152fc8296c3ee0441240fdc2374a8bc",
"size_bytes": 76987031
}
]
}

BIN
releases/v1.7.2-alpha/archipelago Executable file

Binary file not shown.

BIN
releases/v1.7.3-alpha/archipelago Executable file

Binary file not shown.