Compare commits

..
Author SHA1 Message Date
DorianandClaude Fable 5 3d986b81a0 fix(ui): login keeps the intro video until the first login; rotation after
The login page sometimes swapped to a random bg-intro-N still on load,
breaking the seamless handoff from the intro video. Now: until anyone
has logged in / set a password (neode_first_login_done, stamped by every
Login success path incl. TOTP and setup), /login keeps the VIDEO
background — one continuous shot from the splash. From the second login
onward the lock screen rotates through the static backgrounds as
designed, and if the static path is ever hit pre-first-login it pins to
bg-intro.jpg (the video's end frame) instead of rotating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:34:06 +01:00
ai 2efed23afd Merge pull request 'fix(cloud): music opens the bottom-bar player, never the lightbox' (#88) from audio-bottom-bar into main
Demo images / Build & push demo images (push) Successful in 2m36s
2026-07-15 10:24:19 +00:00
DorianandClaude Fable 5 df403c5a69 fix(cloud): music opens the bottom-bar player, never the lightbox
FileCard audio clicks now emit play (global GlobalAudioPlayer bar) —
wired through the FileGrid list view, the Cloud My Files list and own
search results. Peer Files already used the bottom bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:23:57 +01:00
ai a34634e00f Merge pull request 'feat(demo): intro on every fresh visit + neutral desktop Cloud tabs' (#87) from demo-intro-every-visit into main
Demo images / Build & push demo images (push) Successful in 2m35s
2026-07-15 10:13:30 +00:00
DorianandClaude Fable 5 c13ee58edf feat(demo): typing splash + intro on every fresh visit; neutral desktop Cloud tabs
- Demo boots at '/' (first visit or a browser refresh) always start with
  the typing splash into the onboarding intro — the per-calendar-day
  gate is gone. In-session SPA navigation never replays it, and
  deep-route refreshes keep the visitor's place.
- Cloud top-level tabs return to the standard mode-switcher style on
  desktop; the orange full-width switcher stays mobile-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:12:49 +01:00
archipelagoandClaude Fable 5 6e5a99ef71 fix(tests): CloudPeersRefresh mounts Cloud with Pinia (useCloudStore in setup)
Demo images / Build & push demo images (push) Successful in 2m33s
The cloud tabs rework added useCloudStore() to Cloud.vue's setup; this
older test mounted without a Pinia instance and failed the release gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 06:05:53 -04:00
archipelagoandClaude Fable 5 749c351e5e docs(release): v1.7.101-alpha notes + What's New sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:59:53 -04:00
archipelagoandClaude Fable 5 dc897064cd fix(iso): derive ARCHIPELAGO_HOST_IP from the default route, not hostname -I
Demo images / Build & push demo images (push) Successful in 2m39s
The unit's ExecStartPre baked the first hostname -I token into
host-ip.env, which config.rs prefers over its own detection — once a
VPN/bridge interface existed (netbird's wg tunnel), every host_ip
consumer (install configs, VPN, mint URLs) got the tunnel IP. Read the
main-table default route's src address instead, falling back to
hostname -I on routeless hosts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
archipelagoandClaude Fable 5 178ba85c5c fix(system): hostname rename updates /etc/hosts and re-announces mDNS
server.set-name ran hostnamectl but left Debian's 127.0.1.1 line on the
old name (breaking sudo's 'unable to resolve host' and hostname -f) and
waited on avahi to notice the change by itself. Sync /etc/hosts and
avahi-set-host-name (fallback: try-restart avahi-daemon) right after a
successful rename, so http(s)://<hostname>.local works immediately —
best-effort, never blocks the rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
archipelagoandClaude Fable 5 0f5ea9ae15 fix(tor): resolve app ports dynamically + auto-create hidden service on install
'Add Service' only worked for the 19 apps in the hardcoded
known_service_port map — everything else failed with 'Unknown app'.
tor.create-service and tor.toggle-app now fall back to the app's live
launch address from the scanner state, so any manifest-driven app gets
a .onion without a per-app entry (the static map still wins for
protocol services like bitcoin, which must expose 8333, not a UI port).
toggle-app also no longer writes a broken port-0 HiddenServicePort for
unknown apps.

Every successful package.install now auto-creates the app's hidden
service (detached, best-effort, retries while the scanner derives the
port; skips protocol services and existing entries).

The demo mock gains stateful tor.create-service/tor.delete-service so
the demo round-trips instead of erroring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
archipelagoandClaude Fable 5 5d4d40e9e8 style: cargo fmt the Ark wallet merge (release-gate fmt guard)
Formatting only — no logic changes to PR #78.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
ai 8caa4c7d07 Merge pull request 'fix(ui): restore first-login dashboard entrance + working Replay Intro' (#86) from intro-entrance-fixes into main
Demo images / Build & push demo images (push) Successful in 2m36s
2026-07-15 09:49:14 +00:00
DorianandClaude Fable 5 4983ba4e20 fix(ui): first-login dashboard entrance restored + working Replay Intro
- playDashboardLoadOomph gated itself silent on the one entry that
  should be loud: onboarding is already flagged complete by the time the
  post-wizard dashboard mounts. force flag restores the full oomph there
  while keeping ordinary re-logins quiet (the intentional limit).
- OnboardingDone now hands Login a one-shot finale flag, so the login
  right after the wizard gets the full zoom + oomph even as a regular
  password login — which is exactly the demo flow (and TOTP logins).
- 'Replay intro' on the login screen actually replays: App.vue was
  instantly re-marking the intro as seen on onboarded nodes; an explicit
  one-shot replay flag now overrides every suppression rule (unit
  tested).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:48:39 +01:00
ai 994795e4d2 Merge pull request 'feat(cloud): tab/list feedback + rich demo peer content' (#85) from cloud-feedback-demo-content into main
Demo images / Build & push demo images (push) Successful in 2m33s
2026-07-15 09:36:33 +00:00
25 changed files with 346 additions and 95 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog # Changelog
## v1.7.101-alpha (2026-07-15)
- The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.
- Every app you install now automatically gets its own private .onion address — your apps are reachable over Tor a few seconds after install, with no manual "Add Service" step.
- "Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".
- Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.
- The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.
- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, and a search that also finds files shared by your federated peer nodes.
- The first-login dashboard entrance animation is back, and "Replay Intro" in Settings actually replays it.
- Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.
- The public demo is richer and truer: Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.
- Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).
## v1.7.100-alpha (2026-07-14) ## v1.7.100-alpha (2026-07-14)
- Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs. - Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs.
+8 -2
View File
@@ -55,14 +55,20 @@ impl RpcHandler {
})) }))
} }
}; };
let bal = client.balance().await.unwrap_or_else(|_| serde_json::json!({})); let bal = client
.balance()
.await
.unwrap_or_else(|_| serde_json::json!({}));
let sat = |key: &str| bal.get(key).and_then(|v| v.as_u64()).unwrap_or(0); let sat = |key: &str| bal.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
let spendable = sat("spendable_sat"); let spendable = sat("spendable_sat");
let pending = sat("pending_in_round_sat") let pending = sat("pending_in_round_sat")
+ sat("pending_board_sat") + sat("pending_board_sat")
+ sat("pending_lightning_send_sat") + sat("pending_lightning_send_sat")
+ sat("claimable_lightning_receive_sat") + sat("claimable_lightning_receive_sat")
+ bal.get("pending_exit_sat").and_then(|v| v.as_u64()).unwrap_or(0); + bal
.get("pending_exit_sat")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let onchain = client let onchain = client
.onchain_balance() .onchain_balance()
.await .await
+1 -1
View File
@@ -804,7 +804,7 @@ async fn http_launch_url_reachable(url: &str) -> bool {
} }
} }
fn port_from_url(url: &str) -> Option<u16> { pub(in crate::api::rpc) fn port_from_url(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1; let after_colon = url.rsplit_once(':')?.1;
let port = after_colon let port = after_colon
.chars() .chars()
@@ -110,6 +110,14 @@ impl RpcHandler {
) )
.await; .await;
handler.clear_install_progress(&package_id_spawn).await; handler.clear_install_progress(&package_id_spawn).await;
// Auto-expose the app over Tor (best-effort, detached) —
// every installed app gets its .onion without a manual
// "Add Service" step.
let tor_handler = Arc::clone(&handler);
let tor_app = package_id_spawn.clone();
tokio::spawn(async move {
tor_handler.auto_add_tor_service(&tor_app).await;
});
} }
Err(e) => { Err(e) => {
error!("package.install {} failed: {:#}", package_id_spawn, e); error!("package.install {} failed: {:#}", package_id_spawn, e);
@@ -53,6 +53,7 @@ impl RpcHandler {
// hit a hostname-mismatch warning on top of the usual self-signed one // hit a hostname-mismatch warning on top of the usual self-signed one
// the moment a node is renamed. // the moment a node is renamed.
if hostname_updated { if hostname_updated {
sync_hostname_side_effects(&hostname).await;
if let Err(e) = regenerate_tls_cert(&hostname).await { if let Err(e) = regenerate_tls_cert(&hostname).await {
warn!(hostname = %hostname, "TLS cert regen after rename failed: {}", e); warn!(hostname = %hostname, "TLS cert regen after rename failed: {}", e);
} }
@@ -375,6 +376,46 @@ pub(super) fn hostname_from_server_name(name: &str) -> String {
} }
} }
/// Post-rename side effects that keep the OS consistent with the new
/// hostname — all best-effort, the rename itself has already succeeded.
/// Debian resolves the local hostname via the 127.0.1.1 line in /etc/hosts;
/// leaving the old name there breaks `sudo` ("unable to resolve host") and
/// `hostname -f`. And while avahi eventually follows the kernel hostname,
/// re-announcing immediately makes http(s)://<hostname>.local links work
/// right after the rename instead of minutes later.
async fn sync_hostname_side_effects(hostname: &str) {
// hostname_from_server_name guarantees [a-z0-9-], safe to interpolate.
let script = format!(
"if grep -q '^127\\.0\\.1\\.1' /etc/hosts; then sed -i 's/^127\\.0\\.1\\.1.*/127.0.1.1\\t{h}/' /etc/hosts; else printf '127.0.1.1\\t{h}\\n' >> /etc/hosts; fi",
h = hostname
);
match tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/bin/sh", "-c", &script])
.output()
.await
{
Ok(o) if o.status.success() => {}
Ok(o) => warn!(
"/etc/hosts hostname sync failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => warn!("/etc/hosts hostname sync failed: {}", e),
}
let republished = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/avahi-set-host-name", hostname])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if !republished {
let _ = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/systemctl", "try-restart", "avahi-daemon"])
.output()
.await;
}
}
async fn set_system_hostname(hostname: &str) -> Result<()> { async fn set_system_hostname(hostname: &str) -> Result<()> {
let output = tokio::process::Command::new("/usr/bin/sudo") let output = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/hostnamectl", "set-hostname", hostname]) .args(["-n", "/usr/bin/hostnamectl", "set-hostname", hostname])
+72 -8
View File
@@ -33,14 +33,12 @@ impl RpcHandler {
validate_service_name(name)?; validate_service_name(name)?;
let local_port = if raw_port == 0 { let local_port = if raw_port == 0 {
let detected = known_service_port(name); self.resolve_app_local_port(name).await.ok_or_else(|| {
if detected == 0 { anyhow::anyhow!(
return Err(anyhow::anyhow!( "No local web port found for '{}' — the app isn't running or exposes no UI port; specify local_port manually",
"Unknown app '{}' — specify local_port manually",
name name
)); )
} })?
detected
} else { } else {
raw_port raw_port
}; };
@@ -286,7 +284,12 @@ impl RpcHandler {
"changed": false, "changed": false,
})); }));
} }
let port = known_service_port(app_id); let port = self.resolve_app_local_port(app_id).await.ok_or_else(|| {
anyhow::anyhow!(
"No local web port found for '{}' — the app isn't running or exposes no UI port",
app_id
)
})?;
let is_proto = is_protocol_service(app_id); let is_proto = is_protocol_service(app_id);
config.services.push(TorServiceEntry { config.services.push(TorServiceEntry {
name: app_id.to_string(), name: app_id.to_string(),
@@ -330,6 +333,67 @@ impl RpcHandler {
})) }))
} }
/// The host-local port Tor should forward to for an app: the static map
/// first (protocol apps like bitcoin must expose 8333, not a UI port),
/// then the live launch address the scanner derived from the app's
/// published container ports — so any manifest-driven app works without
/// a per-app entry.
pub(in crate::api::rpc) async fn resolve_app_local_port(&self, name: &str) -> Option<u16> {
let known = known_service_port(name);
if known != 0 {
return Some(known);
}
let (data, _) = self.state_manager.get_snapshot().await;
let lan = data
.package_data
.get(name)?
.installed
.as_ref()?
.interface_addresses
.get("main")?
.lan_address
.clone()?;
crate::api::rpc::container::port_from_url(&lan)
}
/// Best-effort auto-exposure of a freshly installed app as a Tor hidden
/// service. Skips protocol services (bitcoin/lnd keep their explicit
/// flows), the node's own service, apps that already have one, and apps
/// with no resolvable web port. Runs detached after install — it never
/// fails the caller, it only logs.
pub(in crate::api::rpc) async fn auto_add_tor_service(&self, app_id: &str) {
if app_id == "archipelago" || is_protocol_service(app_id) {
return;
}
let config_dir = self.config.data_dir.join("tor-config");
// The scanner may still be deriving the launch address on slower
// nodes; retry for up to ~5 minutes before giving up quietly.
for _ in 0..10u32 {
let config = load_services_config(&config_dir).await;
if config.services.iter().any(|s| s.name == app_id) {
return;
}
if let Some(port) = self.resolve_app_local_port(app_id).await {
let params = serde_json::json!({ "name": app_id, "local_port": port });
match self.handle_tor_create_service(Some(params)).await {
Ok(v) => info!(
app = app_id,
port,
onion = ?v.get("onion_address"),
"Auto-created Tor hidden service after install"
),
Err(e) => warn!(app = app_id, "Auto Tor service creation failed: {}", e),
}
return;
}
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
}
debug!(
app = app_id,
"No web port resolved — skipping auto Tor service"
);
}
/// Restart Tor daemon (system or container). /// Restart Tor daemon (system or container).
pub(in crate::api::rpc) async fn handle_tor_restart(&self) -> Result<serde_json::Value> { pub(in crate::api::rpc) async fn handle_tor_restart(&self) -> Result<serde_json::Value> {
info!("Manual Tor restart requested"); info!("Manual Tor restart requested");
+15 -8
View File
@@ -210,7 +210,10 @@ impl ArkClient {
/// the call itself succeeds). /// the call itself succeeds).
pub async fn spendable_sats(&self) -> Result<u64> { pub async fn spendable_sats(&self) -> Result<u64> {
let bal = self.balance().await?; let bal = self.balance().await?;
Ok(bal.get("spendable_sat").and_then(|v| v.as_u64()).unwrap_or(0)) Ok(bal
.get("spendable_sat")
.and_then(|v| v.as_u64())
.unwrap_or(0))
} }
/// `GET /api/v1/onchain/balance` — the wallet's on-chain (boarding) funds. /// `GET /api/v1/onchain/balance` — the wallet's on-chain (boarding) funds.
@@ -220,7 +223,9 @@ impl ArkClient {
/// `POST /api/v1/wallet/addresses/next` — fresh Ark (`tark1…`) address. /// `POST /api/v1/wallet/addresses/next` — fresh Ark (`tark1…`) address.
pub async fn ark_address(&self) -> Result<String> { pub async fn ark_address(&self) -> Result<String> {
let res = self.post("/api/v1/wallet/addresses/next", serde_json::json!({})).await?; let res = self
.post("/api/v1/wallet/addresses/next", serde_json::json!({}))
.await?;
res.get("address") res.get("address")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from) .map(String::from)
@@ -229,7 +234,9 @@ impl ArkClient {
/// `POST /api/v1/onchain/addresses/next` — fresh on-chain boarding address. /// `POST /api/v1/onchain/addresses/next` — fresh on-chain boarding address.
pub async fn onchain_address(&self) -> Result<String> { pub async fn onchain_address(&self) -> Result<String> {
let res = self.post("/api/v1/onchain/addresses/next", serde_json::json!({})).await?; let res = self
.post("/api/v1/onchain/addresses/next", serde_json::json!({}))
.await?;
res.get("address") res.get("address")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from) .map(String::from)
@@ -277,7 +284,10 @@ impl ArkClient {
) )
.await .await
} }
None => self.post("/api/v1/boards/board-all", serde_json::json!({})).await, None => {
self.post("/api/v1/boards/board-all", serde_json::json!({}))
.await
}
} }
} }
@@ -353,10 +363,7 @@ pub async fn load_ark_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTra
Ok(m) => m, Ok(m) => m,
Err(_) => return Vec::new(), Err(_) => return Vec::new(),
}; };
movements movements.iter().filter_map(movement_to_tx).collect()
.iter()
.filter_map(movement_to_tx)
.collect()
} }
/// Convert one barkd `Movement` into an [`EcashTransaction`]. `None` for /// Convert one barkd `Movement` into an [`EcashTransaction`]. `None` for
+4 -1
View File
@@ -21,7 +21,10 @@ EnvironmentFile=-/var/lib/archipelago/telemetry.env
Environment="XDG_RUNTIME_DIR=/run/user/1000" Environment="XDG_RUNTIME_DIR=/run/user/1000"
# + prefix runs these as root (needed for chown/mkdir outside ReadWritePaths) # + prefix runs these as root (needed for chown/mkdir outside ReadWritePaths)
ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000' ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000'
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk "{print $$1}")" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env' # Host IP from the main-table default route — hostname -I token order breaks
# once a VPN/bridge interface exists (netbird's wg tunnel sorted first and
# poisoned every host_ip consumer). Falls back to hostname -I when routeless.
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && IP=$(ip -4 route show default 2>/dev/null | sed -n "s/.* src \([0-9.]*\).*/\1/p" | head -1); [ -n "$$IP" ] || IP=$(hostname -I 2>/dev/null | awk "{print $$1}"); echo "ARCHIPELAGO_HOST_IP=$$IP" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
ExecStart=/usr/local/bin/archipelago ExecStart=/usr/local/bin/archipelago
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
+44 -7
View File
@@ -2636,20 +2636,45 @@ app.post('/rpc/v1', (req, res) => {
// Tor & Peer Networking // Tor & Peer Networking
// ========================================================================= // =========================================================================
case 'tor.list-services': { case 'tor.list-services': {
mockState.torServices = mockState.torServices || defaultTorServices()
return res.json({ return res.json({
result: { result: {
tor_running: true, tor_running: true,
services: [ services: mockState.torServices,
{ name: 'archipelago', local_port: 5678, onion_address: 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'bitcoin', local_port: 8333, onion_address: 'btcmockxj4k5pnw7hv8qz9jcbr2dwefys6ockqzf1mzjlvxot5ioad.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'lnd', local_port: 9735, onion_address: 'lndmockab3c4def5ghi6jkl7mno8pqr9stu0vwx1yz2ab3c4def5ghi.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'electrs', local_port: 50001, onion_address: 'elecmockyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba1xyz9wvu8tsr7q.onion', enabled: true, unauthenticated: true, protocol: false },
{ name: 'nostr-relay', local_port: 7777, onion_address: null, enabled: false, unauthenticated: true, protocol: false },
],
}, },
}) })
} }
case 'tor.create-service': {
mockState.torServices = mockState.torServices || defaultTorServices()
const name = params?.name
if (!name) {
return res.json({ error: { code: -1, message: 'Missing name' } })
}
if (mockState.torServices.some(s => s.name === name)) {
return res.json({ error: { code: -1, message: `Service '${name}' already exists` } })
}
const stem = `${name.toLowerCase().replace(/[^a-z0-9]/g, '')}demo`
const onion = (stem + 'x7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioadmnopqrstuv').slice(0, 56) + '.onion'
const svc = { name, local_port: params?.local_port || 8080, onion_address: onion, enabled: true, unauthenticated: false, protocol: false }
mockState.torServices = [...mockState.torServices, svc]
console.log(`[Tor] Created hidden service: ${name}${onion}`)
return res.json({ result: { created: true, name, onion_address: onion } })
}
case 'tor.delete-service': {
mockState.torServices = mockState.torServices || defaultTorServices()
const name = params?.name
if (name === 'archipelago') {
return res.json({ error: { code: -1, message: "Cannot delete the node's own Tor service" } })
}
if (!mockState.torServices.some(s => s.name === name)) {
return res.json({ error: { code: -1, message: `Service '${name}' not found` } })
}
mockState.torServices = mockState.torServices.filter(s => s.name !== name)
return res.json({ result: { deleted: true, name } })
}
case 'node.tor-address': { case 'node.tor-address': {
return res.json({ return res.json({
result: { result: {
@@ -5119,6 +5144,18 @@ const mockData = sessionBucketProxy('mockData')
const walletState = sessionBucketProxy('walletState') const walletState = sessionBucketProxy('walletState')
const userState = sessionBucketProxy('userState') const userState = sessionBucketProxy('userState')
const mockState = sessionBucketProxy('mockState') const mockState = sessionBucketProxy('mockState')
// Seed for the per-session Tor services demo state (tor.list-services /
// tor.create-service / tor.delete-service round-trip against this).
function defaultTorServices() {
return [
{ name: 'archipelago', local_port: 5678, onion_address: 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'bitcoin', local_port: 8333, onion_address: 'btcmockxj4k5pnw7hv8qz9jcbr2dwefys6ockqzf1mzjlvxot5ioad.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'lnd', local_port: 9735, onion_address: 'lndmockab3c4def5ghi6jkl7mno8pqr9stu0vwx1yz2ab3c4def5ghi.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'electrs', local_port: 50001, onion_address: 'elecmockyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba1xyz9wvu8tsr7q.onion', enabled: true, unauthenticated: true, protocol: false },
{ name: 'nostr-relay', local_port: 7777, onion_address: null, enabled: false, unauthenticated: true, protocol: false },
]
}
const bitcoinRelayMockState = sessionBucketProxy('bitcoinRelayMockState') const bitcoinRelayMockState = sessionBucketProxy('bitcoinRelayMockState')
// Demo session lifecycle: keyed by the `demo_sid` cookie, capped, idle-reaped. // Demo session lifecycle: keyed by the `demo_sid` cookie, capped, idle-reaped.
+12 -1
View File
@@ -395,6 +395,16 @@ onMounted(async () => {
let seenIntro = localStorage.getItem('neode_intro_seen') === '1' let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1' const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot') if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
// One-shot "Replay intro" request from the login screen must survive the
// auto-re-mark below (which otherwise instantly suppresses the replay on any
// onboarded node).
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
// Public demo: every fresh boot at the root (first visit or a browser
// refresh) starts with the typing splash for the full effect. In-session SPA
// navigation never remounts App, and deep-route refreshes keep their place.
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO && route.path === '/') replayRequested = true
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
const splashCandidate = !seenIntro const splashCandidate = !seenIntro
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')) && (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
@@ -408,7 +418,7 @@ onMounted(async () => {
} }
} }
if (!seenIntro && onboardingComplete === true) { if (!replayRequested && !seenIntro && onboardingComplete === true) {
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ } try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
seenIntro = true seenIntro = true
} }
@@ -421,6 +431,7 @@ onMounted(async () => {
fromBoot, fromBoot,
devMode: import.meta.env.VITE_DEV_MODE, devMode: import.meta.env.VITE_DEV_MODE,
onboardingComplete, onboardingComplete,
replayRequested,
})) { })) {
// Coming from boot screen show the full splash intro (Enter to Exit typing logo) // Coming from boot screen show the full splash intro (Enter to Exit typing logo)
showSplash.value = true showSplash.value = true
+5 -2
View File
@@ -103,6 +103,7 @@ const emit = defineEmits<{
delete: [path: string] delete: [path: string]
share: [path: string, name: string, isDir: boolean] share: [path: string, name: string, isDir: boolean]
preview: [path: string] preview: [path: string]
play: [path: string, name: string]
}>() }>()
const cloudStore = useCloudStore() const cloudStore = useCloudStore()
@@ -122,8 +123,10 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
function handleClick() { function handleClick() {
if (props.item.isDir) { if (props.item.isDir) {
emit('navigate', props.item.path) emit('navigate', props.item.path)
} else if (isImage.value || isVideo.value || isAudio.value) { } else if (isAudio.value) {
// MediaLightbox handles all three media kinds. // Music goes to the global bottom-bar player, not the lightbox.
emit('play', props.item.path, props.item.name)
} else if (isImage.value || isVideo.value) {
emit('preview', props.item.path) emit('preview', props.item.path)
} else { } else {
// Non-media files open by downloading a click should always act on the file. // Non-media files open by downloading a click should always act on the file.
@@ -53,6 +53,7 @@
:item="item" :item="item"
@navigate="$emit('navigate', $event)" @navigate="$emit('navigate', $event)"
@delete="$emit('delete', $event)" @delete="$emit('delete', $event)"
@play="(path, name) => $emit('play', path, name)"
@share="(path, name, isDir) => $emit('share', path, name, isDir)" @share="(path, name, isDir) => $emit('share', path, name, isDir)"
@preview="$emit('preview', $event)" @preview="$emit('preview', $event)"
/> />
+6 -32
View File
@@ -1,10 +1,10 @@
/** /**
* Public-demo helpers. * Public-demo helpers.
* *
* The demo build (VITE_DEMO=1) replays the intro/onboarding on each visit, but * The demo build (VITE_DEMO=1) replays the typing splash + intro/onboarding on
* only once per calendar day per browser tracked in localStorage so it * EVERY fresh boot of the app at '/' (first visit or browser refresh) see
* survives the short-lived backend session. Also exposes the shared demo * App.vue and RootRedirect.demoRoute. Also exposes the shared demo credentials
* credentials shown on the login screen. * shown on the login screen.
*/ */
export const IS_DEMO = export const IS_DEMO =
@@ -13,36 +13,10 @@ export const IS_DEMO =
/** Memorable shared password for the public demo (must match the mock backend). */ /** Memorable shared password for the public demo (must match the mock backend). */
export const DEMO_PASSWORD = 'entertoexit' export const DEMO_PASSWORD = 'entertoexit'
const INTRO_DATE_KEY = 'demo_intro_date' /** Forget any legacy per-day gate marker (pre-2026-07 builds stored one). */
function todayKey(): string {
// Local calendar day, e.g. "2026-06-22".
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** True if this browser already watched the intro earlier today. */
export function demoIntroSeenToday(): boolean {
try {
return localStorage.getItem(INTRO_DATE_KEY) === todayKey()
} catch {
return false
}
}
/** Record that the intro has been seen today, so it won't replay until tomorrow. */
export function markDemoIntroSeen(): void {
try {
localStorage.setItem(INTRO_DATE_KEY, todayKey())
} catch {
/* ignore (private mode / storage disabled) */
}
}
/** Forget today's "seen" marker so the intro plays again (e.g. "Replay Intro"). */
export function clearDemoIntroSeen(): void { export function clearDemoIntroSeen(): void {
try { try {
localStorage.removeItem(INTRO_DATE_KEY) localStorage.removeItem('demo_intro_date')
} catch { } catch {
/* ignore */ /* ignore */
} }
+9 -2
View File
@@ -251,8 +251,15 @@ export function playKeyboardTypingSound() {
} }
/** Gaming-style boot thud - soft impact when dashboard loads */ /** Gaming-style boot thud - soft impact when dashboard loads */
export function playDashboardLoadOomph() { /**
if (!isFirstInstallPhase()) return * @param force Play regardless of install phase. The first dashboard entry
* right after the onboarding wizard needs this: by then onboarding is already
* flagged complete in localStorage, so the isFirstInstallPhase() gate (which
* exists to keep ordinary re-logins quiet) would silence the one entrance
* that SHOULD be loud.
*/
export function playDashboardLoadOomph(force = false) {
if (!force && !isFirstInstallPhase()) return
const ctx = getContext() const ctx = getContext()
if (!ctx) return if (!ctx) return
@@ -28,4 +28,14 @@ describe('shouldShowIntroSplash', () => {
onboardingComplete: null, onboardingComplete: null,
})).toBe(false) })).toBe(false)
}) })
it('an explicit replay request overrides every suppression rule', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: true,
replayRequested: true,
})).toBe(true)
})
}) })
+3
View File
@@ -4,9 +4,12 @@ export interface IntroSplashDecisionInput {
fromBoot: boolean fromBoot: boolean
devMode?: string devMode?: string
onboardingComplete: boolean | null onboardingComplete: boolean | null
/** Explicit "Replay intro" click — overrides every suppression rule. */
replayRequested?: boolean
} }
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean { export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
if (input.replayRequested) return true
if (input.seenIntro) return false if (input.seenIntro) return false
if (input.onboardingComplete === true) return false if (input.onboardingComplete === true) return false
+16 -5
View File
@@ -6,12 +6,12 @@
<!-- Desktop: page tabs + category tabs + search on one row --> <!-- Desktop: page tabs + category tabs + search on one row -->
<div class="app-header-desktop items-center gap-4"> <div class="app-header-desktop items-center gap-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="cloud-tab-switcher hidden md:inline-flex"> <div class="mode-switcher hidden md:inline-flex">
<button <button
v-for="tab in TABS" v-for="tab in TABS"
:key="tab.id" :key="tab.id"
class="cloud-tab-btn" class="mode-switcher-btn"
:class="{ 'cloud-tab-btn-active': activeTab === tab.id }" :class="{ 'mode-switcher-btn-active': activeTab === tab.id }"
@click="activeTab = tab.id" @click="activeTab = tab.id"
>{{ tab.name }}</button> >{{ tab.name }}</button>
</div> </div>
@@ -93,6 +93,7 @@
:item="r.item" :item="r.item"
@delete="handleDelete" @delete="handleDelete"
@share="handleShare" @share="handleShare"
@play="handlePlay"
@preview="(p: string) => handlePreview(p, searchMineItems)" @preview="(p: string) => handlePreview(p, searchMineItems)"
/> />
<button <button
@@ -141,6 +142,7 @@
:item="item" :item="item"
@delete="handleDelete" @delete="handleDelete"
@share="handleShare" @share="handleShare"
@play="handlePlay"
@preview="(p: string) => handlePreview(p, filteredMyFiles)" @preview="(p: string) => handlePreview(p, filteredMyFiles)"
/> />
</div> </div>
@@ -359,6 +361,7 @@ import { useCloudStore } from '../stores/cloud'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client' import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { getFileCategory } from '../composables/useFileType' import { getFileCategory } from '../composables/useFileType'
import { useAudioPlayer } from '../composables/useAudioPlayer'
import FileCard from '../components/cloud/FileCard.vue' import FileCard from '../components/cloud/FileCard.vue'
import ShareModal from '../components/cloud/ShareModal.vue' import ShareModal from '../components/cloud/ShareModal.vue'
import MediaLightbox from '../components/cloud/MediaLightbox.vue' import MediaLightbox from '../components/cloud/MediaLightbox.vue'
@@ -366,6 +369,7 @@ import MediaLightbox from '../components/cloud/MediaLightbox.vue'
const router = useRouter() const router = useRouter()
const store = useAppStore() const store = useAppStore()
const cloudStore = useCloudStore() const cloudStore = useCloudStore()
const audioPlayer = useAudioPlayer()
const sectionCounts = ref<Record<string, number>>({}) const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false) const countsLoading = ref(false)
@@ -565,6 +569,12 @@ function handleShare(path: string, name: string, isDir: boolean) {
shareTarget.value = { path, name, isDir } shareTarget.value = { path, name, isDir }
} }
/** Music opens in the global bottom-bar player (GlobalAudioPlayer in App.vue). */
async function handlePlay(path: string, name: string) {
const url = await cloudStore.streamUrl(path)
audioPlayer.play(url, name)
}
function handlePreview(path: string, context: FileBrowserItem[]) { function handlePreview(path: string, context: FileBrowserItem[]) {
// MediaLightbox filters to media internally; index within that filtered list. // MediaLightbox filters to media internally; index within that filtered list.
const mediaItems = context.filter(item => { const mediaItems = context.filter(item => {
@@ -789,8 +799,9 @@ defineExpose({ loadPeers })
</script> </script>
<style scoped> <style scoped>
/* Top-level tab switcher deliberately a DIFFERENT look from the neutral /* Mobile-only top-level tab switcher deliberately a DIFFERENT look from the
category pills below it: orange-tinted container + active state. */ neutral category pills below it (orange tint). Desktop keeps the standard
mode-switcher styling. */
.cloud-tab-switcher { .cloud-tab-switcher {
gap: 2px; gap: 2px;
padding: 3px; padding: 3px;
+3 -1
View File
@@ -369,7 +369,9 @@ onMounted(() => {
if (loginTransition.justCompletedOnboarding) { if (loginTransition.justCompletedOnboarding) {
// Full glitchy reveal only on the very first dashboard entry // Full glitchy reveal only on the very first dashboard entry
// right after onboarding (one-time event, persists in feel). // right after onboarding (one-time event, persists in feel).
playDashboardLoadOomph() // force=true: onboarding is already marked complete by now, so the
// un-forced call would gate itself silent.
playDashboardLoadOomph(true)
showZoomIn.value = true showZoomIn.value = true
loginTransition.setPendingWelcomeTyping(true) loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustCompletedOnboarding(false) loginTransition.setJustCompletedOnboarding(false)
+25
View File
@@ -426,6 +426,8 @@ async function handleSetup() {
playLoginSuccessWhoosh() playLoginSuccessWhoosh()
loginTransition.setJustCompletedOnboarding(true) loginTransition.setJustCompletedOnboarding(true)
loginTransition.setJustLoggedIn(true) loginTransition.setJustLoggedIn(true)
// First password setup counts as the first login (video static rotation).
try { localStorage.setItem('neode_first_login_done', '1') } catch { /* ignore */ }
await new Promise(r => setTimeout(r, 520)) await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => { await router.replace(loginRedirectTo.value).catch(() => {
window.location.href = loginRedirectTo.value window.location.href = loginRedirectTo.value
@@ -477,6 +479,7 @@ async function handleLogin() {
stopSynthwave() stopSynthwave()
whooshAway.value = true whooshAway.value = true
playLoginSuccessWhoosh() playLoginSuccessWhoosh()
consumeOnboardingFinale()
loginTransition.setJustLoggedIn(true) loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520)) await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => { await router.replace(loginRedirectTo.value).catch(() => {
@@ -512,6 +515,7 @@ async function handleTotpVerify() {
stopSynthwave() stopSynthwave()
whooshAway.value = true whooshAway.value = true
playLoginSuccessWhoosh() playLoginSuccessWhoosh()
consumeOnboardingFinale()
loginTransition.setJustLoggedIn(true) loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520)) await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => { await router.replace(loginRedirectTo.value).catch(() => {
@@ -533,11 +537,32 @@ async function handleTotpVerify() {
} }
} }
/** A login right after the onboarding wizard (flag set by OnboardingDone)
* gets the FULL dashboard entrance zoom + oomph even though it's a
* regular password login (e.g. the demo). Subsequent logins stay
* deliberately low-key (justLoggedIn only).
* Also stamps neode_first_login_done: the login page keeps the intro VIDEO
* background until someone has logged in once (OnboardingWrapper reads it
* to pick video vs the rotating static backgrounds). */
function consumeOnboardingFinale() {
try {
if (sessionStorage.getItem('archy_onboarding_finale') === '1') {
sessionStorage.removeItem('archy_onboarding_finale')
loginTransition.setJustCompletedOnboarding(true)
}
localStorage.setItem('neode_first_login_done', '1')
} catch { /* ignore */ }
}
function replayIntro() { function replayIntro() {
// Clear the intro seen flag // Clear the intro seen flag
localStorage.removeItem('neode_intro_seen') localStorage.removeItem('neode_intro_seen')
// Demo: also clear the per-day gate so the intro plays again now. // Demo: also clear the per-day gate so the intro plays again now.
if (IS_DEMO) clearDemoIntroSeen() if (IS_DEMO) clearDemoIntroSeen()
// On an onboarded node App.vue instantly re-marks the intro as seen (that's
// what keeps fresh browsers on already-onboarded nodes from replaying it)
// this explicit one-shot flag tells it the replay is deliberate.
try { sessionStorage.setItem('archipelago_replay_intro', '1') } catch { /* ignore */ }
// Navigate to root to trigger splash screen // Navigate to root to trigger splash screen
window.location.href = '/' window.location.href = '/'
} }
+4
View File
@@ -120,6 +120,10 @@ onUnmounted(() => {
function goToLogin() { function goToLogin() {
playNavSound('action') playNavSound('action')
// The login that follows the wizard gets the full dashboard entrance
// (zoom + oomph) even when it's a regular password login (e.g. the demo)
// Login.vue consumes this flag on success.
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
router.push('/login').catch(() => {}) router.push('/login').catch(() => {})
} }
</script> </script>
+1 -4
View File
@@ -56,16 +56,13 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import AnimatedLogo from '@/components/AnimatedLogo.vue' import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { playNavSound } from '@/composables/useNavSounds' import { playNavSound } from '@/composables/useNavSounds'
import { IS_DEMO, markDemoIntroSeen } from '@/composables/useDemoIntro' import { IS_DEMO } from '@/composables/useDemoIntro'
const router = useRouter() const router = useRouter()
const ctaButton = ref<HTMLButtonElement | null>(null) const ctaButton = ref<HTMLButtonElement | null>(null)
const isDemo = IS_DEMO const isDemo = IS_DEMO
onMounted(() => { onMounted(() => {
// Demo: once the visitor has seen the intro today, don't auto-replay it again
// until tomorrow (they can still use "Replay Intro" on the login screen).
if (IS_DEMO) markDemoIntroSeen()
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration) // Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
setTimeout(() => { setTimeout(() => {
ctaButton.value?.focus({ preventScroll: true }) ctaButton.value?.focus({ preventScroll: true })
+18 -14
View File
@@ -86,22 +86,22 @@ const videoBackgroundRoutes = ['/onboarding/intro', '/login']
// Login uses video when coming from splash, or static + glitch when direct // Login uses video when coming from splash, or static + glitch when direct
const isLoginRoute = computed(() => route.path === '/login') const isLoginRoute = computed(() => route.path === '/login')
// True once onboarding is complete. Used to skip the intro video on
// the /login route so that returning (logged-out) users go straight // Check if current route should use video background.
// to the screensaver-style static + glitch background instead of // The FIRST login (nobody has logged in / set a password yet) keeps the VIDEO
// replaying the full intro every time. // running so the splash login handoff is one continuous shot; only after a
const onboardingDone = computed(() => { // successful login does /login switch to the rotating static backgrounds.
// Login.vue sets neode_first_login_done on every successful login.
const hasLoggedInBefore = () => {
try { try {
return localStorage.getItem('neode_onboarding_complete') === '1' return localStorage.getItem('neode_first_login_done') === '1'
} catch { } catch {
return false return false
} }
}) }
// Check if current route should use video background
const useVideoBackground = computed(() => { const useVideoBackground = computed(() => {
if (!videoBackgroundRoutes.includes(route.path)) return false if (!videoBackgroundRoutes.includes(route.path)) return false
if (route.path === '/login' && onboardingDone.value) return false if (route.path === '/login' && hasLoggedInBefore()) return false
return true return true
}) })
@@ -122,9 +122,12 @@ const routeBackgrounds: Record<string, string> = {
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro) '/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
} }
// Rotate the login background so the lock screen doesn't look // Until the user has logged in (or set their password) once, the login
// identical on every logout. Cycles through bg-intro-1..6 using a // background stays PINNED to the same still the intro video ends on the
// counter persisted to localStorage so subsequent visits advance. // video login handoff only reads as one continuous shot when both show the
// identical frame. From the second login onward the lock screen rotates
// through bg-intro-1..6 for variety (counter persisted in localStorage).
// Login.vue sets neode_first_login_done on every successful login.
const LOGIN_BACKGROUNDS = [ const LOGIN_BACKGROUNDS = [
'bg-intro-1.webp', 'bg-intro-1.webp',
'bg-intro-2.jpg', 'bg-intro-2.jpg',
@@ -135,13 +138,14 @@ const LOGIN_BACKGROUNDS = [
] ]
function pickNextLoginBackground(): string { function pickNextLoginBackground(): string {
try { try {
if (localStorage.getItem('neode_first_login_done') !== '1') return 'bg-intro.jpg'
const raw = localStorage.getItem('neode_login_bg_idx') const raw = localStorage.getItem('neode_login_bg_idx')
const prev = raw !== null ? parseInt(raw, 10) : -1 const prev = raw !== null ? parseInt(raw, 10) : -1
const next = (Number.isFinite(prev) ? prev + 1 : 0) % LOGIN_BACKGROUNDS.length const next = (Number.isFinite(prev) ? prev + 1 : 0) % LOGIN_BACKGROUNDS.length
localStorage.setItem('neode_login_bg_idx', String(next)) localStorage.setItem('neode_login_bg_idx', String(next))
return LOGIN_BACKGROUNDS[next]! return LOGIN_BACKGROUNDS[next]!
} catch { } catch {
return LOGIN_BACKGROUNDS[Math.floor(Math.random() * LOGIN_BACKGROUNDS.length)]! return 'bg-intro.jpg'
} }
} }
const loginBackground = ref(pickNextLoginBackground()) const loginBackground = ref(pickNextLoginBackground())
+6 -6
View File
@@ -16,20 +16,20 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { isOnboardingComplete } from '@/composables/useOnboarding' import { isOnboardingComplete } from '@/composables/useOnboarding'
import { IS_DEMO, demoIntroSeenToday } from '@/composables/useDemoIntro' import { IS_DEMO } from '@/composables/useDemoIntro'
import BootScreen from '@/components/BootScreen.vue' import BootScreen from '@/components/BootScreen.vue'
const router = useRouter() const router = useRouter()
const showBootScreen = ref(false) const showBootScreen = ref(false)
/** /**
* Public demo: replay the intro on every visit, but at most once per calendar * Public demo: every fresh boot of the app (first visit OR browser refresh at
* day per browser. If already seen today straight to login; otherwise intro. * the root) replays the intro for the full effect. In-session SPA navigation
* never re-triggers it this only runs when the app boots at '/'.
*/ */
function demoRoute() { function demoRoute() {
const dest = demoIntroSeenToday() ? '/login' : '/onboarding/intro' log('demoRoute', { dest: '/onboarding/intro' })
log('demoRoute', { dest }) router.replace('/onboarding/intro').catch(() => {})
router.replace(dest).catch(() => {})
} }
function log(msg: string, data?: unknown) { function log(msg: string, data?: unknown) {
@@ -1,4 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import Cloud from '../Cloud.vue' import Cloud from '../Cloud.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
@@ -43,7 +44,7 @@ describe('Cloud peer list', () => {
it('keeps peer nodes visible while refresh is pending or fails', async () => { it('keeps peer nodes visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.federationListNodes).mockResolvedValueOnce({ nodes: [makePeer()] }) vi.mocked(rpcClient.federationListNodes).mockResolvedValueOnce({ nodes: [makePeer()] })
const wrapper = mount(Cloud) const wrapper = mount(Cloud, { global: { plugins: [createPinia()] } })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Peer Alpha') expect(wrapper.text()).toContain('Peer Alpha')
@@ -362,6 +362,25 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.101-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.101-alpha</span>
<span class="text-xs text-white/40">July 15, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.</p>
<p>Every app you install now automatically gets its own private .onion address your apps are reachable over Tor a few seconds after install, with no manual "Add Service" step.</p>
<p>"Add Service" in the Tor panel now works for every app, not just a fixed list the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".</p>
<p>Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.</p>
<p>The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.</p>
<p>Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, and a search that also finds files shared by your federated peer nodes.</p>
<p>The first-login dashboard entrance animation is back, and "Replay Intro" in Settings actually replays it.</p>
<p>Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.</p>
<p>The public demo is richer and truer: Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.</p>
<p>Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).</p>
</div>
</div>
<!-- v1.7.100-alpha --> <!-- v1.7.100-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">