diff --git a/core/Cargo.lock b/core/Cargo.lock index 4aeb6de6..fba439ef 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.28-alpha" +version = "1.7.29-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 9b0d4124..9c1d849a 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.28-alpha" +version = "1.7.29-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index ce049ab7..d0ffacf9 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -120,8 +120,8 @@ impl ApiHandler { /// first 2xx response. 15s total timeout. async fn handle_app_catalog_proxy() -> Result> { const UPSTREAMS: &[&str] = &[ - "https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json", "http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json", + "https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json", ]; let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index b88921f7..4536cfc7 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -220,6 +220,7 @@ impl RpcHandler { "registry.list" => self.handle_registry_list().await, "registry.add" => self.handle_registry_add(params).await, "registry.remove" => self.handle_registry_remove(params).await, + "registry.set-primary" => self.handle_registry_set_primary(params).await, "registry.test" => self.handle_registry_test(params).await, // Streaming ecash payments diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 6fa0b50f..e6d77a2b 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -649,8 +649,24 @@ impl RpcHandler { ); let _ = std::fs::create_dir_all(&user_tmp); + // Rewrite to the primary registry's URL so the first attempt + // honors the operator's mirror choice (default: VPS) instead of + // blindly using whatever registry the image was hardcoded to. + // If the rewritten URL fails, pull_from_registries_with_skip + // falls through to the other configured registries. + let (primary_url, primary_tls) = + crate::container::registry::primary_image_url(&self.config.data_dir, docker_image) + .await; + if primary_url != docker_image { + debug!("Rewrote {} → {} for primary registry", docker_image, primary_url); + } + + let mut pull_args = vec!["pull".to_string(), primary_url.clone()]; + if !primary_tls { + pull_args.push("--tls-verify=false".to_string()); + } let mut child = tokio::process::Command::new("podman") - .args(["pull", docker_image]) + .args(&pull_args) .env("TMPDIR", &user_tmp) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -684,23 +700,35 @@ impl RpcHandler { true } Err(_) => { - tracing::warn!("Image pull timed out after 60s: {}", docker_image); + tracing::warn!("Image pull timed out after 60s: {}", primary_url); let _ = child.kill().await; let _ = child.wait().await; // reap zombie true } }; + if !primary_failed && primary_url != docker_image { + // Primary pull succeeded but used a rewritten URL. Tag under + // the original image reference so downstream code (images -q, + // run -d docker_image, etc.) finds it. + let _ = tokio::process::Command::new("podman") + .args(["tag", &primary_url, docker_image]) + .output() + .await; + tracing::info!("Pulled {} from primary registry ({})", docker_image, primary_url); + } if primary_failed { - // Try all configured fallback registries dynamically - match crate::container::registry::pull_from_registries( + // Primary failed — walk the remaining configured registries. + // Skip primary_url so we don't retry what just failed. + match crate::container::registry::pull_from_registries_with_skip( &self.config.data_dir, docker_image, &user_tmp, + Some(&primary_url), ) .await { Ok(_) => { - tracing::info!("Pulled {} via dynamic registry fallback", docker_image); + tracing::info!("Pulled {} via fallback registry", docker_image); } Err(e) => { return Err(anyhow::anyhow!("Image pull failed: {}", e)); @@ -1467,6 +1495,36 @@ server { Ok(serde_json::json!({ "registries": config.registries, "removed": url })) } + /// Promote a registry to primary by resetting priorities — the named + /// URL becomes priority 0, every other enabled registry is bumped up + /// by 10. Order is stable (ties broken by original priority). + pub(in crate::api::rpc) async fn handle_registry_set_primary( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let url = params + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing url"))?; + + let mut config = crate::container::registry::load_registries(&self.config.data_dir).await?; + if !config.registries.iter().any(|r| r.url == url) { + return Err(anyhow::anyhow!("Registry '{}' not found", url)); + } + + // Reassign priorities: target = 0, everyone else = 10, 20, 30… + // in their existing priority order. + let target_url = url.to_string(); + config.registries.sort_by_key(|r| (r.url != target_url, r.priority)); + for (i, r) in config.registries.iter_mut().enumerate() { + r.priority = if r.url == target_url { 0 } else { (i as u32) * 10 }; + } + + crate::container::registry::save_registries(&self.config.data_dir, &config).await?; + Ok(serde_json::json!({ "registries": config.registries, "primary": url })) + } + pub(in crate::api::rpc) async fn handle_registry_test( &self, params: Option, diff --git a/core/archipelago/src/container/registry.rs b/core/archipelago/src/container/registry.rs index 3b9715b4..cdf8f112 100644 --- a/core/archipelago/src/container/registry.rs +++ b/core/archipelago/src/container/registry.rs @@ -44,16 +44,16 @@ impl Default for RegistryConfig { Self { registries: vec![ Registry { - url: "git.tx1138.com/lfg2025".to_string(), - name: "Archipelago Primary".to_string(), - tls_verify: true, + url: "23.182.128.160:3000/lfg2025".to_string(), + name: "Server 1 (VPS)".to_string(), + tls_verify: false, enabled: true, priority: 0, }, Registry { - url: "23.182.128.160:3000/lfg2025".to_string(), - name: "Archipelago Fallback".to_string(), - tls_verify: false, + url: "git.tx1138.com/lfg2025".to_string(), + name: "Server 2 (tx1138)".to_string(), + tls_verify: true, enabled: true, priority: 10, }, @@ -94,6 +94,28 @@ impl RegistryConfig { candidates } + + /// Rewrite an image to use the highest-priority enabled registry, so + /// the FIRST pull attempt honors the operator's primary choice instead + /// of blindly using whatever registry the image URL was hardcoded to. + /// Returns (rewritten_url, tls_verify) — or the original URL + default + /// tls_verify=true if there's no primary (no enabled registries). + pub fn rewrite_for_primary(&self, image: &str) -> (String, bool) { + match self.active_registries().first() { + Some(primary) => (self.rewrite_image(image, primary), primary.tls_verify), + None => (image.to_string(), true), + } + } +} + +/// Load the registry config and rewrite an image to use the primary +/// registry's URL. Convenience wrapper for callers that don't already +/// have a `RegistryConfig` in hand. +pub async fn primary_image_url(data_dir: &Path, image: &str) -> (String, bool) { + match load_registries(data_dir).await { + Ok(config) => config.rewrite_for_primary(image), + Err(_) => (image.to_string(), true), + } } /// Extract the image name from a full image reference. @@ -134,10 +156,20 @@ pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result } /// Try pulling an image from configured registries in priority order. +/// If `already_tried` is Some, that URL is skipped (avoids retrying the +/// primary when the caller already attempted it with progress streaming). /// Returns the image reference that succeeded. -pub async fn pull_from_registries(data_dir: &Path, image: &str, tmpdir: &str) -> Result { +pub async fn pull_from_registries_with_skip( + data_dir: &Path, + image: &str, + tmpdir: &str, + already_tried: Option<&str>, +) -> Result { let config = load_registries(data_dir).await?; - let candidates = config.image_candidates(image); + let mut candidates = config.image_candidates(image); + if let Some(skip) = already_tried { + candidates.retain(|(url, _)| url != skip); + } for (candidate, tls_verify) in &candidates { debug!("Trying registry: {}", candidate); @@ -196,6 +228,7 @@ pub async fn pull_from_registries(data_dir: &Path, image: &str, tmpdir: &str) -> )) } + #[cfg(test)] mod tests { use super::*; @@ -217,9 +250,11 @@ mod tests { #[test] fn test_rewrite_image() { let config = RegistryConfig::default(); - let fallback = &config.registries[1]; + // Default primary is now VPS (index 0). A tx1138-hardcoded image + // rewrites to VPS when asked for the primary mirror. + let primary = &config.registries[0]; assert_eq!( - config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", fallback), + config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary), "23.182.128.160:3000/lfg2025/bitcoin-knots:latest" ); } @@ -228,8 +263,20 @@ mod tests { fn test_image_candidates() { let config = RegistryConfig::default(); let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"); - assert!(candidates.len() >= 2); - assert_eq!(candidates[0].0, "git.tx1138.com/lfg2025/lnd:v0.18.4-beta"); + // Defaults: VPS (primary) + tx1138. tx1138 is filtered out because + // it's identical to the original image URL, leaving one candidate. + assert_eq!(candidates.len(), 1); + // Primary-first — VPS rewrite leads the candidate list. + assert_eq!(candidates[0].0, "23.182.128.160:3000/lfg2025/lnd:v0.18.4-beta"); + } + + #[test] + fn test_rewrite_for_primary_uses_top_priority() { + let config = RegistryConfig::default(); + let (url, tls) = + config.rewrite_for_primary("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"); + assert_eq!(url, "23.182.128.160:3000/lfg2025/lnd:v0.18.4-beta"); + assert!(!tls, "VPS primary is HTTP — tls_verify should be false"); } #[test] diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index 42a211d4..57483315 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -193,6 +193,11 @@ const router = createRouter({ name: 'system-update', component: () => import('../views/SystemUpdate.vue'), }, + { + path: 'settings/registries', + name: 'app-registries', + component: () => import('../views/AppRegistries.vue'), + }, { path: 'goals/:goalId', name: 'goal-detail', diff --git a/neode-ui/src/views/AppRegistries.vue b/neode-ui/src/views/AppRegistries.vue new file mode 100644 index 00000000..8d5cfcd3 --- /dev/null +++ b/neode-ui/src/views/AppRegistries.vue @@ -0,0 +1,330 @@ + + + diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index fc918aea..12ba7b23 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -180,6 +180,19 @@ init()
+ +
+
+ v1.7.29-alpha + Apr 21, 2026 +
+
+

New App registries page in Settings — same experience as Update mirrors, but for the container registries your node pulls app images from. Add a mirror, test reachability with one click, pick the primary.

+

New nodes default to the VPS registry as the primary for both app installs and the app catalog, with tx1138 as the automatic fallback if the VPS is slow or unreachable. Existing nodes keep whatever registry order they've already set.

+

App installs now genuinely honor the primary registry: the first pull attempt rewrites the image URL to use your primary, and only falls through to the secondary if that fails. Before, installs always hit whichever registry the image was hardcoded to.

+

Reboot screen now shows the animated "a" logo in the center of the ring — matching the screensaver's look so you get something nice to watch while the node comes back up.

+
+
diff --git a/neode-ui/src/views/settings/AppRegistriesSection.vue b/neode-ui/src/views/settings/AppRegistriesSection.vue new file mode 100644 index 00000000..4bc8d55f --- /dev/null +++ b/neode-ui/src/views/settings/AppRegistriesSection.vue @@ -0,0 +1,27 @@ + + + diff --git a/neode-ui/src/views/settings/SystemDangerZone.vue b/neode-ui/src/views/settings/SystemDangerZone.vue index 7a62ae09..47a3c336 100644 --- a/neode-ui/src/views/settings/SystemDangerZone.vue +++ b/neode-ui/src/views/settings/SystemDangerZone.vue @@ -4,6 +4,7 @@ import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { rpcClient } from '@/api/rpc-client' import ScreensaverRing from '@/components/ScreensaverRing.vue' +import ScreensaverLogo from '@/components/ScreensaverLogo.vue' const router = useRouter() const { t } = useI18n() @@ -170,8 +171,13 @@ async function performFactoryReset() { v-if="rebootOverlay" class="fixed inset-0 z-[3000] bg-black flex flex-col items-center justify-center overflow-hidden" > - - + +
+ +
+ +
+
@@ -258,6 +264,20 @@ async function performFactoryReset() { opacity: 0; } +.reboot-ring-content { + position: relative; + display: grid; + place-items: center; +} +.reboot-logo-wrapper { + position: absolute; + inset: 0; + display: grid; + place-items: center; + z-index: 10; + filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15)); +} + .reboot-overlay-bar-anim { animation: rebootBarSlide 1.8s ease-in-out infinite; } diff --git a/neode-ui/src/views/settings/SystemSection.vue b/neode-ui/src/views/settings/SystemSection.vue index 94365792..78b5c2ac 100644 --- a/neode-ui/src/views/settings/SystemSection.vue +++ b/neode-ui/src/views/settings/SystemSection.vue @@ -3,6 +3,7 @@ import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue' import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue' import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue' import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue' +import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue' import WebhookSection from '@/views/settings/WebhookSection.vue' import TelemetrySection from '@/views/settings/TelemetrySection.vue' import BackupSection from '@/views/settings/BackupSection.vue' @@ -14,6 +15,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue' + diff --git a/releases/manifest.json b/releases/manifest.json index c2f2a695..6417e477 100644 --- a/releases/manifest.json +++ b/releases/manifest.json @@ -1,26 +1,28 @@ { - "version": "1.7.28-alpha", + "version": "1.7.29-alpha", "release_date": "2026-04-21", "changelog": [ - "Reboot now shows a proper progress screen. Click Reboot and you'll see a full-screen overlay with the familiar pulsing ring, a rebooting / reconnecting / back-online status, and an elapsed counter. The page auto-reloads the moment your node is back up; if it takes longer than three minutes, a manual Reload button appears.", - "New nodes default to the VPS mirror as Server 1 (primary) and tx1138 as Server 2 (fallback). Existing nodes keep whatever mirror order they've already set — use Set Primary on the System Update page to change it any time." + "New App registries page in Settings — same experience as Update mirrors, but for the container registries your node pulls app images from. Add a mirror, test reachability with one click, pick the primary.", + "New nodes default to the VPS registry as the primary for both app installs and the app catalog, with tx1138 as the automatic fallback if the VPS is slow or unreachable. Existing nodes keep whatever registry order they've already set.", + "App installs now genuinely honor the primary registry: the first pull attempt rewrites the image URL to use your primary, and only falls through to the secondary if that fails. Before, installs always hit whichever registry the image was hardcoded to.", + "Reboot screen now shows the animated 'a' logo in the center of the ring — matching the screensaver's look so you get something nice to watch while the node comes back up." ], "components": [ { "name": "archipelago", - "current_version": "1.7.27-alpha", - "new_version": "1.7.28-alpha", - "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.28-alpha/archipelago", - "sha256": "eba06b1c10a90a796e14b9e810900f77a6774ba7dfc782bb26b41d7f7800a605", - "size_bytes": 40888296 + "current_version": "1.7.28-alpha", + "new_version": "1.7.29-alpha", + "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.29-alpha/archipelago", + "sha256": "38cb4f99c2af896de2f10db358b68824e07744c34c89d0e8d0e8b41c78c0cf33", + "size_bytes": 40753856 }, { - "name": "archipelago-frontend-1.7.28-alpha.tar.gz", - "current_version": "1.7.27-alpha", - "new_version": "1.7.28-alpha", - "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.28-alpha/archipelago-frontend-1.7.28-alpha.tar.gz", - "sha256": "c7eec98be09b0f7f9f04fcbfea594bea4f2a738943cdaa3427251b402ac62620", - "size_bytes": 77000104 + "name": "archipelago-frontend-1.7.29-alpha.tar.gz", + "current_version": "1.7.28-alpha", + "new_version": "1.7.29-alpha", + "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.29-alpha/archipelago-frontend-1.7.29-alpha.tar.gz", + "sha256": "0b2033d029324966d9ad7dcd2de745b1037964365596b8b9fb55a84c9396050b", + "size_bytes": 77004776 } ] } diff --git a/releases/v1.7.29-alpha/archipelago b/releases/v1.7.29-alpha/archipelago new file mode 100755 index 00000000..634c61f1 Binary files /dev/null and b/releases/v1.7.29-alpha/archipelago differ diff --git a/releases/v1.7.29-alpha/archipelago-frontend-1.7.29-alpha.tar.gz b/releases/v1.7.29-alpha/archipelago-frontend-1.7.29-alpha.tar.gz new file mode 100644 index 00000000..4bb9fc8c Binary files /dev/null and b/releases/v1.7.29-alpha/archipelago-frontend-1.7.29-alpha.tar.gz differ