feat(identity,update): default avatars, public blobs, long-running downloads
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled

Follow-up to 1fb71b4b on the same v1.7.0-alpha line.

Identity avatars
  • New module `avatar.rs` generates two deterministic SVG styles keyed
    off the pubkey: a 5×5 mirrored identicon for sub-identities and a
    hexagonal-network motif for the master (seed index 0) identity.
    Both returned as base64 data URLs, so a fresh identity has a
    recognisable picture before the user uploads anything.
  • `IdentityManager::create()` and `create_from_seed()` populate
    `profile.picture` on creation. Index 0 gets the node SVG; all
    other seed-derived + ad-hoc identities get the identicon.

Blob store — public flag for profile assets
  • `BlobMeta.public` (default false) added; `BlobStore::put()` takes
    a `public: bool`. Missing in legacy meta files = false.
  • `POST /api/blob` now stores uploads with public=true and returns
    `public_url` alongside `self_test_url`. public_url is
    `http://<node-onion>/blob/<cid>` (no cap) if Tor has published the
    archipelago hidden service, else falls back to the local path.
  • `GET /blob/<cid>` bypasses the HMAC capability check when the
    requested blob is flagged public — external Nostr clients fetching
    a kind-0 `picture` URL can't hold a cap.
  • Mesh callers (content_ref attachments, dispatch rehydration) pin
    public=false explicitly so nothing leaks out of the mesh path.

Profile editor UX
  • Collapsed Save + Save & Publish into one button — the Save action
    now persists locally AND publishes the kind-0 metadata event in
    one step. Uploads store `public_url` into `profile.picture` /
    `profile.banner` so the published URL is reachable by external
    clients.

Update client — the 15-second cliff
  • Frontend `rpcClient.call` for `update.download` now has an
    explicit 30-minute timeout (was falling back to the default 15 s).
    `update.apply` gets 5 min, `update.git-apply` gets 15 min. Matches
    what the backend is actually willing to wait for.
  • Backend `load_state()` reconciles `state.current_version` with
    `CARGO_PKG_VERSION` on every start. Sideloaded or reflashed nodes
    were stuck advertising the old version even with a new binary in
    place, which kept re-offering the same release as an update.

Manifest changelog rewritten for fleet readers per the saved feedback
(no function names, no file paths). Artefacts refreshed:
  binary   12f838c5…5ba82d  40381864
  frontend dc3b63af…e9a8370 76984288

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-20 10:03:38 -04:00
parent 1fb71b4b4e
commit df83163f15
14 changed files with 347 additions and 79 deletions

View File

@@ -9,12 +9,33 @@ use super::{build_response, ApiHandler};
use crate::blobs::BlobStore;
use anyhow::Result;
use hyper::{Body, HeaderMap, Response, StatusCode};
use std::path::Path;
use std::sync::Arc;
/// Read the archipelago .onion address if Tor has published one, so uploads
/// that need to be publicly reachable (profile pictures, banners) can return
/// a URL a peer outside the LAN can actually fetch. Returns `None` before
/// onboarding or when Tor isn't running — callers fall back to the local
/// self-test URL.
async fn read_self_onion(data_dir: &Path) -> Option<String> {
let hostnames = data_dir.join("tor-hostnames").join("archipelago");
let legacy = Path::new("/var/lib/archipelago/tor-hostnames/archipelago");
for p in [hostnames.as_path(), legacy] {
if let Ok(s) = tokio::fs::read_to_string(p).await {
let trimmed = s.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
impl ApiHandler {
pub(super) async fn handle_blob_upload(
store: &Arc<BlobStore>,
self_pubkey_hex: &str,
data_dir: &Path,
headers: &HeaderMap,
body: hyper::body::Bytes,
) -> Result<Response<Body>> {
@@ -29,10 +50,13 @@ impl ApiHandler {
.map(|s| s.to_string());
let bytes = body.to_vec();
match store.put(&bytes, &mime, filename, None).await {
// Uploads through /api/blob come from the node owner's session and
// are almost always intended for external consumption (profile
// pictures, banners). Store them public so `/blob/<cid>` serves
// without a capability check — external Nostr clients fetching a
// kind-0 `picture` URL have no cap and can't get one.
match store.put(&bytes, &mime, filename, None, true).await {
Ok(meta) => {
// Include a self-signed capability URL so the UI can round-trip
// the upload end-to-end without any peer. 7-day expiry.
let exp =
(chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp);
@@ -40,11 +64,19 @@ impl ApiHandler {
"/blob/{}?cap={}&exp={}&peer={}",
meta.cid, cap, exp, self_pubkey_hex
);
let public_url = match read_self_onion(data_dir).await {
Some(onion) => format!("http://{}/blob/{}", onion, meta.cid),
// Pre-onboarding / Tor-not-up: surface the local path so
// the UI doesn't break; publishing to Nostr should wait
// until Tor is live anyway.
None => format!("/blob/{}", meta.cid),
};
let resp = serde_json::json!({
"cid": meta.cid,
"size": meta.size,
"mime": meta.mime,
"filename": meta.filename,
"public_url": public_url,
"self_test_url": self_test_url,
});
Ok(build_response(
@@ -83,7 +115,7 @@ impl ApiHandler {
.map(|s| s.to_string());
let bytes = body.to_vec();
let meta = match store.put(&bytes, &mime, filename, None).await {
let meta = match store.put(&bytes, &mime, filename, None, false).await {
Ok(m) => m,
Err(e) => {
return Ok(build_response(
@@ -134,34 +166,41 @@ impl ApiHandler {
));
}
// Parse query params: cap, exp, peer (all required)
let mut cap = None;
let mut exp: Option<u64> = None;
let mut peer = None;
for pair in query.split('&') {
let mut it = pair.splitn(2, '=');
match (it.next(), it.next()) {
(Some("cap"), Some(v)) => cap = Some(v.to_string()),
(Some("exp"), Some(v)) => exp = v.parse().ok(),
(Some("peer"), Some(v)) => peer = Some(v.to_string()),
_ => {}
}
}
let (Some(cap), Some(exp), Some(peer)) = (cap, exp, peer) else {
return Ok(build_response(
StatusCode::UNAUTHORIZED,
"text/plain",
Body::from("missing cap/exp/peer"),
));
};
// Public blobs (profile pictures, banners) bypass the capability
// check — their CID is published on Nostr relays where any reader
// can see it, and external readers have no way to obtain a cap.
// Only blobs explicitly marked public at upload time qualify.
let is_public = store.meta(cid).await.map(|m| m.public).unwrap_or(false);
if let Err(e) = store.verify_capability(cid, &peer, exp, &cap) {
tracing::warn!("blob cap rejected: cid={} peer={} reason={}", cid, peer, e);
return Ok(build_response(
StatusCode::FORBIDDEN,
"text/plain",
Body::from(format!("capability rejected: {}", e)),
));
if !is_public {
let mut cap = None;
let mut exp: Option<u64> = None;
let mut peer = None;
for pair in query.split('&') {
let mut it = pair.splitn(2, '=');
match (it.next(), it.next()) {
(Some("cap"), Some(v)) => cap = Some(v.to_string()),
(Some("exp"), Some(v)) => exp = v.parse().ok(),
(Some("peer"), Some(v)) => peer = Some(v.to_string()),
_ => {}
}
}
let (Some(cap), Some(exp), Some(peer)) = (cap, exp, peer) else {
return Ok(build_response(
StatusCode::UNAUTHORIZED,
"text/plain",
Body::from("missing cap/exp/peer"),
));
};
if let Err(e) = store.verify_capability(cid, &peer, exp, &cap) {
tracing::warn!("blob cap rejected: cid={} peer={} reason={}", cid, peer, e);
return Ok(build_response(
StatusCode::FORBIDDEN,
"text/plain",
Body::from(format!("capability rejected: {}", e)),
));
}
}
let bytes = match store.get(cid).await {

View File

@@ -291,6 +291,7 @@ impl ApiHandler {
Self::handle_blob_upload(
&self.blob_store,
&self.self_pubkey_hex,
&self.config.data_dir,
&headers,
body_bytes,
)

View File

@@ -411,7 +411,7 @@ impl RpcHandler {
.clone()
};
let meta = blob_store
.put(&bytes, &mime, filename.clone(), None)
.put(&bytes, &mime, filename.clone(), None, false)
.await?;
let service = self.mesh_service.read().await;
@@ -761,7 +761,7 @@ impl RpcHandler {
.await
.map_err(|e| anyhow::anyhow!("Read body failed: {}", e))?;
let meta = blob_store.put(&bytes, &mime, filename_hint, None).await?;
let meta = blob_store.put(&bytes, &mime, filename_hint, None, false).await?;
if meta.cid != cid {
anyhow::bail!("CID mismatch: expected {}, got {}", cid, meta.cid);
}

View File

@@ -0,0 +1,203 @@
//! Deterministic default avatars derived from a Nostr/Ed25519 pubkey.
//!
//! Two flavours are generated as base64-encoded SVG data URLs so they can
//! live directly in `IdentityProfile.picture` without any blob-store round
//! trip:
//!
//! - [`identicon`] — a 5×5 symmetric grid (GitHub-style) for sub-identities.
//! - [`master_node_svg`] — a hexagonal-network motif for the primary
//! seed-derived identity (derivation index 0). Distinct at a glance from
//! the identicons so the user can tell their own node at 48 px.
//!
//! Both read the first 8 bytes of the hex pubkey, so the same key always
//! produces the same avatar — useful for reconstructing history without
//! storing the blob.
use base64::Engine;
/// Convert a byte to an HSL triple biased toward readable foregrounds on
/// dark backgrounds (saturation 6085%, lightness 5270%).
fn hue_color(seed: u8) -> String {
let hue = (seed as u16) * 360 / 256;
format!("hsl({}, 72%, 60%)", hue)
}
fn accent_color(seed: u8) -> String {
let hue = (seed as u16) * 360 / 256;
format!("hsl({}, 80%, 68%)", hue)
}
fn encode_svg(svg: &str) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
format!("data:image/svg+xml;base64,{}", b64)
}
/// Parse the first 8 bytes from a hex pubkey. Returns `[0u8; 8]` if the
/// input is too short or malformed — callers get a consistent default
/// avatar rather than an error.
fn seed_bytes(pubkey_hex: &str) -> [u8; 8] {
let mut out = [0u8; 8];
let clean: String = pubkey_hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
for (i, byte) in out.iter_mut().enumerate() {
let lo = i * 2;
if clean.len() >= lo + 2 {
*byte = u8::from_str_radix(&clean[lo..lo + 2], 16).unwrap_or(0);
}
}
out
}
/// 5×5 mirrored identicon. ~700 bytes of SVG, ~1 KB as a data URL.
pub fn identicon(pubkey_hex: &str) -> String {
let bytes = seed_bytes(pubkey_hex);
let fg = hue_color(bytes[0]);
let bg = "#171a24";
// 15 bit slots (3 visible columns × 5 rows). Mirror to 5×5.
// Use bytes[1..=2] as 16 bits, drop the MSB so we get 15.
let bits = u16::from_be_bytes([bytes[1], bytes[2]]) & 0x7fff;
let mut cells = String::with_capacity(512);
let cell_px: u32 = 16;
for row in 0..5u32 {
for col in 0..5u32 {
let src_col = if col < 3 { col } else { 4 - col };
let bit_idx = row * 3 + src_col;
if (bits >> bit_idx) & 1 == 1 {
let x = col * cell_px;
let y = row * cell_px;
cells.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"/>",
x, y, cell_px, cell_px
));
}
}
}
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 80 80\" \
shape-rendering=\"crispEdges\">\
<rect width=\"80\" height=\"80\" fill=\"{bg}\"/>\
<g fill=\"{fg}\">{cells}</g>\
</svg>"
);
encode_svg(&svg)
}
/// Hex-network motif for the master (seed-index-0) identity. Central hex
/// plus six ring hexes connected by faint edges, with an accent colour
/// derived from the pubkey. Distinct silhouette from the 5×5 identicon so
/// the node identity reads differently at every size.
pub fn master_node_svg(pubkey_hex: &str) -> String {
let bytes = seed_bytes(pubkey_hex);
let accent = accent_color(bytes[0]);
let accent2 = accent_color(bytes[0].wrapping_add(64));
let pattern = bytes[3] & 0x3f; // 6 bits — one per ring hex
// Hexagon vertices (point-up) at radius 16, centred on (c, c).
let hex_path = |cx: f64, cy: f64, r: f64| -> String {
let mut pts = String::new();
for i in 0..6 {
let theta = std::f64::consts::FRAC_PI_3 * (i as f64) - std::f64::consts::FRAC_PI_2;
let x = cx + r * theta.cos();
let y = cy + r * theta.sin();
if i == 0 {
pts.push_str(&format!("M{:.2},{:.2}", x, y));
} else {
pts.push_str(&format!(" L{:.2},{:.2}", x, y));
}
}
pts.push_str(" Z");
pts
};
let c = 64.0;
let ring_r = 36.0;
// Ring centres (6 hexes at 60° intervals around centre).
let ring_centres: Vec<(f64, f64)> = (0..6)
.map(|i| {
let theta = std::f64::consts::FRAC_PI_3 * (i as f64) - std::f64::consts::FRAC_PI_2;
(c + ring_r * theta.cos(), c + ring_r * theta.sin())
})
.collect();
let mut ring_hexes = String::new();
let mut edges = String::new();
for (i, (rx, ry)) in ring_centres.iter().enumerate() {
// Alternate fill/stroke based on pattern bits so two nodes never
// share the same ring silhouette.
let filled = (pattern >> i) & 1 == 1;
let fill = if filled { &accent } else { "none" };
let stroke_w = if filled { 0.0 } else { 1.4 };
ring_hexes.push_str(&format!(
"<path d=\"{}\" fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\" opacity=\"0.92\"/>",
hex_path(*rx, *ry, 10.5),
fill,
&accent,
stroke_w
));
// Edge from centre to this ring node.
edges.push_str(&format!(
"<line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" \
stroke=\"{}\" stroke-width=\"1\" opacity=\"0.35\"/>",
c, c, rx, ry, &accent2
));
}
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 128 128\">\
<defs>\
<radialGradient id=\"bg\" cx=\"50%\" cy=\"50%\" r=\"60%\">\
<stop offset=\"0%\" stop-color=\"#1c2030\"/>\
<stop offset=\"100%\" stop-color=\"#0a0d16\"/>\
</radialGradient>\
</defs>\
<rect width=\"128\" height=\"128\" fill=\"url(#bg)\"/>\
{edges}\
{ring_hexes}\
<path d=\"{centre_hex}\" fill=\"{accent}\" stroke=\"#ffffff\" stroke-width=\"1.5\"/>\
</svg>",
centre_hex = hex_path(c, c, 16.0),
);
encode_svg(&svg)
}
/// Build a default [`IdentityProfile`]-shaped picture for the given
/// identity. The master (seed index 0) gets the node SVG; everyone else
/// gets the identicon.
pub fn default_picture(pubkey_hex: &str, is_master: bool) -> String {
if is_master {
master_node_svg(pubkey_hex)
} else {
identicon(pubkey_hex)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identicon_is_deterministic() {
let a = identicon("aabbccddeeff0011");
let b = identicon("aabbccddeeff0011");
assert_eq!(a, b);
assert!(a.starts_with("data:image/svg+xml;base64,"));
}
#[test]
fn master_is_distinct_from_identicon() {
let pk = "aabbccddeeff0011";
assert_ne!(identicon(pk), master_node_svg(pk));
}
#[test]
fn handles_short_or_malformed_hex() {
// Shouldn't panic, should still return a valid data URL.
let a = identicon("");
assert!(a.starts_with("data:image/svg+xml;base64,"));
let b = master_node_svg("xyz!!!");
assert!(b.starts_with("data:image/svg+xml;base64,"));
}
}

View File

@@ -34,6 +34,11 @@ pub struct BlobMeta {
/// Stored alongside meta so ContentRef senders don't re-fetch the blob.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumb_bytes: Option<Vec<u8>>,
/// Public blobs (profile pictures, banners) are served at `/blob/<cid>`
/// without a capability check so external Nostr clients can fetch them.
/// Missing in legacy metadata = default false (cap required).
#[serde(default)]
pub public: bool,
}
pub struct BlobStore {
@@ -69,6 +74,7 @@ impl BlobStore {
mime: &str,
filename: Option<String>,
thumb_bytes: Option<Vec<u8>>,
public: bool,
) -> Result<BlobMeta> {
if bytes.len() as u64 > MAX_BLOB_SIZE {
anyhow::bail!(
@@ -87,6 +93,7 @@ impl BlobStore {
filename,
created_at: chrono::Utc::now().to_rfc3339(),
thumb_bytes,
public,
};
let blob_path = self.path_for(&cid);

View File

@@ -162,6 +162,14 @@ impl IdentityManager {
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().to_rfc3339();
// Every new identity gets a deterministic default avatar derived from
// its pubkey. Non-seed identities aren't the master node, so they use
// the 5×5 identicon (never the hexagonal node silhouette).
let default_profile = IdentityProfile {
picture: Some(crate::avatar::identicon(&pubkey_hex)),
..Default::default()
};
let identity_file = IdentityFile {
id: id.clone(),
name: name.clone(),
@@ -172,7 +180,7 @@ impl IdentityManager {
created_at: created_at.clone(),
nostr_secret_hex: None,
nostr_pubkey_hex: None,
profile: None,
profile: Some(default_profile),
derivation_index: None,
};
@@ -230,6 +238,14 @@ impl IdentityManager {
let nostr_secret_hex = nostr_keys.secret_key().display_secret().to_string();
let nostr_pubkey_hex = nostr_keys.public_key().to_hex();
// Derivation index 0 is the primary seed-derived identity — the
// "master" node identity — and gets the distinctive hexagonal SVG.
// Later indices get the standard identicon.
let default_profile = IdentityProfile {
picture: Some(crate::avatar::default_picture(&pubkey_hex, index == 0)),
..Default::default()
};
let identity_file = IdentityFile {
id: id.clone(),
name: name.clone(),
@@ -240,7 +256,7 @@ impl IdentityManager {
created_at: created_at.clone(),
nostr_secret_hex: Some(nostr_secret_hex),
nostr_pubkey_hex: Some(nostr_pubkey_hex),
profile: None,
profile: Some(default_profile),
derivation_index: Some(index),
};

View File

@@ -24,6 +24,7 @@ use tracing::info;
mod api;
mod auth;
mod avatar;
mod backup;
mod bitcoin_rpc;
mod blobs;

View File

@@ -602,6 +602,7 @@ pub(crate) async fn handle_typed_envelope_direct(
&content.mime,
content.filename.clone(),
None,
false,
)
.await
{

View File

@@ -78,7 +78,26 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
let data = fs::read_to_string(&path)
.await
.context("Reading update state")?;
serde_json::from_str(&data).context("Parsing update state")
let mut state: UpdateState =
serde_json::from_str(&data).context("Parsing update state")?;
// Keep current_version in sync with the binary. Sideloaded nodes
// (ssh + cp /usr/local/bin/archipelago) don't touch the state file,
// so without this the running 1.7.0-alpha binary would keep seeing
// `current_version: "1.6.0-alpha"` and re-offer itself as an update.
let running = env!("CARGO_PKG_VERSION");
if state.current_version != running {
state.current_version = running.to_string();
// Clear any stale "available_update" that matched the old
// current_version — the new binary will re-check on its own.
if let Some(ref avail) = state.available_update {
if avail.version == running {
state.available_update = None;
}
}
save_state(data_dir, &state).await?;
}
Ok(state)
}
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {

View File

@@ -357,7 +357,7 @@ async function downloadUpdate() {
total_bytes: number
downloaded_bytes: number
components_downloaded: number
}>({ method: 'update.download' })
}>({ method: 'update.download', timeout: 1_800_000 })
downloadPercent.value = 100
downloaded.value = true
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
@@ -403,7 +403,7 @@ async function applyUpdateGit() {
applying.value = true
statusMessage.value = ''
try {
await rpcClient.call({ method: 'update.git-apply' })
await rpcClient.call({ method: 'update.git-apply', timeout: 900_000 })
showStatus(t('systemUpdate.gitApplyStarted'))
updateInfo.value = null
} catch (e) {
@@ -418,7 +418,7 @@ async function applyUpdate() {
applying.value = true
statusMessage.value = ''
try {
await rpcClient.call({ method: 'update.apply' })
await rpcClient.call({ method: 'update.apply', timeout: 300_000 })
showStatus(t('systemUpdate.applySuccess'))
updateInfo.value = null
downloaded.value = false

View File

@@ -359,8 +359,7 @@
<div v-if="profileSuccess" class="mt-3 alert-success"><p class="text-xs">{{ profileSuccess }}</p></div>
<div class="flex gap-3 mt-5">
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium">{{ profileSaving ? 'Saving...' : 'Save' }}</button>
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">{{ profilePublishing ? 'Publishing...' : 'Save & Publish' }}</button>
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">{{ profilePublishing ? 'Saving & publishing…' : 'Save' }}</button>
</div>
</div>
</div>
@@ -405,16 +404,14 @@ const keyViewerCopied = ref<string | null>(null)
// Profile editor
const profileEditorIdentity = ref<ManagedIdentity | null>(null)
const profileForm = ref<IdentityProfile>({})
const profileSaving = ref(false)
const profilePublishing = ref(false)
const avatarUploading = ref(false)
const bannerUploading = ref(false)
// Upload to local blob store + set the corresponding profile URL so
// the kind:0 event (publish) includes a reachable picture/banner. The
// returned `self_test_url` is a capability-signed /blob/<cid>?cap=…
// path — works locally. For external nostr clients to see the image,
// swap to a public image host later.
// 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
// reachable by external Nostr clients fetching kind:0 metadata.
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
const input = ev.target as HTMLInputElement
const file = input?.files?.[0]
@@ -435,10 +432,10 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
body: buf,
})
if (!resp.ok) throw new Error(`upload failed: HTTP ${resp.status}`)
const { self_test_url } = await resp.json() as { self_test_url?: string }
if (!self_test_url) throw new Error('blob API returned no URL')
// Assign and let the <img> in the header preview react.
profileForm.value[field] = self_test_url
const { public_url, self_test_url } = await resp.json() as { public_url?: string; self_test_url?: string }
const url = public_url || self_test_url
if (!url) throw new Error('blob API returned no URL')
profileForm.value[field] = url
} catch (e: unknown) {
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
} finally {
@@ -577,26 +574,6 @@ function closeProfileEditor() {
profileSuccess.value = ''
}
async function saveProfile() {
if (!profileEditorIdentity.value || profileSaving.value) return
profileSaving.value = true
profileError.value = ''
profileSuccess.value = ''
try {
await rpcClient.call({
method: 'identity.update-profile',
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
})
await loadIdentities()
profileSuccess.value = 'Profile saved'
setTimeout(() => { profileSuccess.value = '' }, 3000)
} catch (err: unknown) {
profileError.value = err instanceof Error ? err.message : 'Failed to save'
} finally {
profileSaving.value = false
}
}
async function publishProfile() {
if (!profileEditorIdentity.value || profilePublishing.value) return
profilePublishing.value = true

View File

@@ -2,12 +2,16 @@
"version": "1.7.0-alpha",
"release_date": "2026-04-20",
"changelog": [
"Your VPN peers now come back automatically after a reboot. No more rescanning QR codes on your phone or laptop.",
"Every identity now gets a personal avatar the moment it's created. Your main node identity gets a distinctive hexagonal-network icon; other identities get a colourful generated pattern unique to each one.",
"Profile editor: upload a profile picture and a banner, then tap Save — your Nostr profile now goes out to the relays in one step. No more 'Save' vs 'Save & Publish' confusion.",
"Profile pictures and banners you upload are now reachable by other Nostr clients across the network — not just your own browser. Anyone who sees your profile on a relay can load the image.",
"Update downloads on slow connections no longer cut out right at the end. The client waits up to 30 minutes for each component instead of giving up after 15 seconds.",
"When you move a node to a new version without going through Check for Updates (for example via a reinstall or manual copy), it now reports the new version correctly instead of endlessly saying 'update available'.",
"Your VPN peers come back automatically after a reboot. No more rescanning QR codes on your phone or laptop.",
"Fresh installs stay lean — only File Browser is included out of the box. Other apps wait in the Marketplace until you pick them.",
"Update downloads no longer give up early on slow connections. Big components now get the time they need to finish.",
"The download progress number is now clean (like 45.23%) instead of 45.270894%.",
"File Browser stops rebooting itself every few hours — the housekeeper now leaves it alone once it's healthy.",
"One-click 'Pull & Rebuild' button works for nodes that update from source (the development path), not just the standard download path.",
"File Browser stops rebooting itself every few hours — it's now set up so the update checker leaves it alone once it's healthy."
"The download progress number is now clean (like 45.23%) instead of 45.270894%."
],
"components": [
{
@@ -15,16 +19,16 @@
"current_version": "1.6.0-alpha",
"new_version": "1.7.0-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.0-alpha/archipelago",
"sha256": "d85a71c57b737a51d0f80c63da0da0f3472b03cb5cbbecae7808aaaaa17982f4",
"size_bytes": 40360936
"sha256": "12f838c502704689aa3185e3a2989b4f002527f8af54cefa776119755b5ba82d",
"size_bytes": 40381864
},
{
"name": "archipelago-frontend-1.7.0-alpha.tar.gz",
"current_version": "1.6.0-alpha",
"new_version": "1.7.0-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.0-alpha/archipelago-frontend-1.7.0-alpha.tar.gz",
"sha256": "8adcdacf4190ae52dd9ed4f447aed1d4c332f1c86c4f5b8919e1a83a02e687f6",
"size_bytes": 76986852
"sha256": "dc3b63afedc45a663a023702ea23b6ac499d5b2731078a9b5a2feb57ae9a8370",
"size_bytes": 76984288
}
]
}

Binary file not shown.