diff --git a/core/archipelago/src/api/handler/blob.rs b/core/archipelago/src/api/handler/blob.rs index 43b67ad4..e8a7a528 100644 --- a/core/archipelago/src/api/handler/blob.rs +++ b/core/archipelago/src/api/handler/blob.rs @@ -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 { + 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, self_pubkey_hex: &str, + data_dir: &Path, headers: &HeaderMap, body: hyper::body::Bytes, ) -> Result> { @@ -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/` 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 = 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 = 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 { diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 1df5a151..3c1f49ed 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -291,6 +291,7 @@ impl ApiHandler { Self::handle_blob_upload( &self.blob_store, &self.self_pubkey_hex, + &self.config.data_dir, &headers, body_bytes, ) diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index 615759a7..6d5767d2 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -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); } diff --git a/core/archipelago/src/avatar.rs b/core/archipelago/src/avatar.rs new file mode 100644 index 00000000..8a69beb6 --- /dev/null +++ b/core/archipelago/src/avatar.rs @@ -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 60–85%, lightness 52–70%). +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!( + "", + x, y, cell_px, cell_px + )); + } + } + } + + let svg = format!( + "\ + \ + {cells}\ + " + ); + 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!( + "", + hex_path(*rx, *ry, 10.5), + fill, + &accent, + stroke_w + )); + // Edge from centre to this ring node. + edges.push_str(&format!( + "", + c, c, rx, ry, &accent2 + )); + } + + let svg = format!( + "\ + \ + \ + \ + \ + \ + \ + \ + {edges}\ + {ring_hexes}\ + \ + ", + 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,")); + } +} diff --git a/core/archipelago/src/blobs.rs b/core/archipelago/src/blobs.rs index 49d0d5e0..df17e993 100644 --- a/core/archipelago/src/blobs.rs +++ b/core/archipelago/src/blobs.rs @@ -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>, + /// Public blobs (profile pictures, banners) are served at `/blob/` + /// 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, thumb_bytes: Option>, + public: bool, ) -> Result { 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); diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 71f83201..5a3827f0 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -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), }; diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 0bfe0c17..97495fba 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -24,6 +24,7 @@ use tracing::info; mod api; mod auth; +mod avatar; mod backup; mod bitcoin_rpc; mod blobs; diff --git a/core/archipelago/src/mesh/listener/dispatch.rs b/core/archipelago/src/mesh/listener/dispatch.rs index 93f693cd..927fac4a 100644 --- a/core/archipelago/src/mesh/listener/dispatch.rs +++ b/core/archipelago/src/mesh/listener/dispatch.rs @@ -602,6 +602,7 @@ pub(crate) async fn handle_typed_envelope_direct( &content.mime, content.filename.clone(), None, + false, ) .await { diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 84768da8..fe301691 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -78,7 +78,26 @@ pub async fn load_state(data_dir: &Path) -> Result { 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<()> { diff --git a/neode-ui/src/views/SystemUpdate.vue b/neode-ui/src/views/SystemUpdate.vue index e15ce108..5e98feef 100644 --- a/neode-ui/src/views/SystemUpdate.vue +++ b/neode-ui/src/views/SystemUpdate.vue @@ -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 diff --git a/neode-ui/src/views/web5/Web5Identities.vue b/neode-ui/src/views/web5/Web5Identities.vue index 9cc79ad6..b9ede713 100644 --- a/neode-ui/src/views/web5/Web5Identities.vue +++ b/neode-ui/src/views/web5/Web5Identities.vue @@ -359,8 +359,7 @@

{{ profileSuccess }}

- - +
@@ -405,16 +404,14 @@ const keyViewerCopied = ref(null) // Profile editor const profileEditorIdentity = ref(null) const profileForm = ref({}) -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/?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 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 diff --git a/releases/manifest.json b/releases/manifest.json index 64e3d3cf..1a0acede 100644 --- a/releases/manifest.json +++ b/releases/manifest.json @@ -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 } ] } diff --git a/releases/v1.7.0-alpha/archipelago b/releases/v1.7.0-alpha/archipelago index 2d1a480d..f9cd1fa8 100755 Binary files a/releases/v1.7.0-alpha/archipelago and b/releases/v1.7.0-alpha/archipelago differ diff --git a/releases/v1.7.0-alpha/archipelago-frontend-1.7.0-alpha.tar.gz b/releases/v1.7.0-alpha/archipelago-frontend-1.7.0-alpha.tar.gz index 70ccde16..2d9cd714 100644 Binary files a/releases/v1.7.0-alpha/archipelago-frontend-1.7.0-alpha.tar.gz and b/releases/v1.7.0-alpha/archipelago-frontend-1.7.0-alpha.tar.gz differ