Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ced95a60d1 | ||
|
|
a0bd9e53f8 | ||
|
|
6137762786 | ||
|
|
8d1fda29fa | ||
|
|
135fb5650b | ||
|
|
1de4a0943e | ||
|
|
b5e33784e6 | ||
|
|
2b7e92e770 | ||
|
|
7c34df36cd | ||
|
|
4b4e1ab1a3 | ||
|
|
d05fa6988d | ||
|
|
2399eeac66 | ||
|
|
63cb9dd22c |
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.3-alpha (2026-08-14)
|
||||
|
||||
- **The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.
|
||||
- **The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.
|
||||
- **App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files.
|
||||
|
||||
## v1.8.2-alpha (2026-08-13)
|
||||
|
||||
- **An app that can't be shown inside the dashboard now becomes a tab app by itself.** A few apps refuse to render inside another page no matter what — they break out with their own code or insist on owning the whole browser window. Opening one used to mean staring at a grey pane. Now the dashboard notices, offers the app in its own tab, and remembers: from then on that app's button opens a tab directly (with the little launch icon that tab apps carry), first click, every time. If a later update makes the app embeddable after all, the dashboard notices that too and goes back to embedding it.
|
||||
- **The logo emblem got its glossy black paint finish — properly this time.** The circle behind the A on the screensaver, intro, and login now wears a deep wet-paint look: warm light blooming from the top edge, fine grain so the dark tones stay smooth instead of banding, and no more ring border. (An earlier rougher version of this experiment briefly shipped by accident and then vanished depending on which screen you were on — this is the finished, deliberate one, everywhere.)
|
||||
- **New app icons now match the store's look, on every screen.** Alby Hub and phoenixd arrived with edge-to-edge logos that ignored the breathing room every other app icon has, and the app detail page skipped the icon backdrop entirely. Both icons are re-set on the standard canvas, the detail page now applies the same icon treatment as the store tiles, and app developers get a one-command tool that puts any logo onto the house canvas automatically.
|
||||
|
||||
## v1.8.1-alpha (2026-08-13)
|
||||
|
||||
- **Apps that refused to open inside the dashboard now embed like everything else.** Some apps ship browser headers that forbid being shown inside another page — correct hardening on the open web, but inside Archipelago it produced a dead grey pane when you opened them from My Apps (Alby Hub was the first to hit it). The app gate, which already checks your login on every request to an app, now removes just those framing headers on the way through; each app's own content-security rules pass through untouched. No more per-app proxy workarounds.
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.3-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.3-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -344,6 +344,7 @@ impl RpcHandler {
|
||||
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
|
||||
"system.settings.get" => self.handle_system_settings_get(params).await,
|
||||
"system.settings.set" => self.handle_system_settings_set(params).await,
|
||||
"system.node-ca.generate" => self.handle_system_node_ca_generate().await,
|
||||
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
|
||||
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
|
||||
"bitcoin.relay-update-settings" => {
|
||||
|
||||
@@ -133,6 +133,7 @@ impl RpcHandler {
|
||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
|
||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||
@@ -503,6 +504,7 @@ impl RpcHandler {
|
||||
"ai.permissions.set" => self.handle_ai_permissions_set(params).await,
|
||||
"system.settings.get" => self.handle_system_settings_get(params).await,
|
||||
"system.settings.set" => self.handle_system_settings_set(params).await,
|
||||
"system.node-ca.generate" => self.handle_system_node_ca_generate().await,
|
||||
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
|
||||
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
|
||||
|
||||
|
||||
@@ -607,9 +607,77 @@ impl RpcHandler {
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// LND returns r_hash base64-encoded; the lookup endpoint the Receive
|
||||
// flow polls (`lnd.invoicestatus`) wants it hex — hand the UI the
|
||||
// ready-to-use form.
|
||||
let r_hash_hex = {
|
||||
use base64::Engine as _;
|
||||
body.get("r_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(hex::encode)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
"amount_sats": amount_sats,
|
||||
"r_hash_hex": r_hash_hex,
|
||||
}))
|
||||
}
|
||||
|
||||
/// lnd.invoicestatus — is this invoice settled yet? Polled by the wallet's
|
||||
/// Receive flow so a Lightning payment gets the same "money has arrived"
|
||||
/// success screen as on-chain (minus the broadcast step: settlement is
|
||||
/// final). Params: `{ "r_hash_hex": string }`.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_invoicestatus(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let r_hash_hex = params
|
||||
.get("r_hash_hex")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'r_hash_hex' parameter"))?;
|
||||
if r_hash_hex.len() != 64 || !r_hash_hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("r_hash_hex must be 64 hex characters"));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{r_hash_hex}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to query invoice")?;
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoice lookup response")?;
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Invoice lookup failed: {}", msg));
|
||||
}
|
||||
|
||||
let settled = body
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s == "SETTLED")
|
||||
.unwrap_or_else(|| body.get("settled").and_then(|v| v.as_bool()).unwrap_or(false));
|
||||
let amt_paid_sat = body
|
||||
.get("amt_paid_sat")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.or_else(|| body.get("amt_paid_sat").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"settled": settled,
|
||||
"amt_paid_sat": amt_paid_sat,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1248,6 +1248,30 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// system.node-ca.generate — run the node's (idempotent) CA setup so the
|
||||
/// dashboard can offer certificate generation as a button. WebUI rule:
|
||||
/// users must never be pointed at a terminal; the script reuses an
|
||||
/// existing CA and only reissues the leaf, so re-running is safe.
|
||||
pub(in crate::api::rpc) async fn handle_system_node_ca_generate(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let script = "/opt/archipelago/scripts/setup-node-ca.sh";
|
||||
if tokio::fs::metadata(script).await.is_err() {
|
||||
anyhow::bail!(
|
||||
"The certificate setup script is not on this node yet — it arrives with the next update."
|
||||
);
|
||||
}
|
||||
let status = host_sudo(&["/usr/bin/bash", script]).await?;
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"Certificate generation failed (exit {:?}) — see the node log for detail",
|
||||
status.code()
|
||||
);
|
||||
}
|
||||
info!("Node CA generated/reissued via dashboard");
|
||||
Ok(serde_json::json!({ "generated": true }))
|
||||
}
|
||||
|
||||
/// system.kiosk-display.get — Current kiosk display preset + whether this
|
||||
/// node has a kiosk at all (no kiosk unit -> the Settings section hides).
|
||||
pub(in crate::api::rpc) async fn handle_system_kiosk_display_get(
|
||||
|
||||
@@ -159,6 +159,14 @@ the top-level origin (OAuth redirect flows, WebAuthn), or sets
|
||||
in the real embedded app session, **not** a plain browser tab: tabs don't
|
||||
enforce framing headers, so a tab proves nothing about the iframe.
|
||||
|
||||
**The dashboard also self-heals**: if a running app's frame fails to load,
|
||||
the session offers "open in tab" and *remembers the app as a tab app* — every
|
||||
later launch opens a tab directly, with the tab-launch icon on its button.
|
||||
The memory clears itself when the app later embeds successfully, and expires
|
||||
weekly so fixes get re-probed. This safety net is not a licence to skip the
|
||||
manifest flag: declaring `open_in_new_tab: true` up front spares your users
|
||||
the one dead-pane encounter the detector needs.
|
||||
|
||||
(Historical note: before the gate handled this, embeddable-but-blocking apps
|
||||
each carried a hand-built nginx strip proxy — gitea's port-3000 proxy is the
|
||||
surviving example. Do not copy that pattern for new apps.)
|
||||
@@ -523,6 +531,20 @@ The broader app update policy for `1.8-alpha` is still being finalized. Until th
|
||||
|
||||
## App Icon
|
||||
|
||||
- **The tile plate (dark rounded background, border, sheen) is applied by
|
||||
the system** — every surface renders your icon with the house
|
||||
`archy-app-icon` treatment automatically. What the system deliberately
|
||||
does NOT add is an inner margin (icons that already carry whitespace
|
||||
would double-margin), so your file needs it baked in: **square canvas,
|
||||
~12% margin per side**. Edge-to-edge marks look wrong next to every
|
||||
other tile. Don't hand-tune it: run your mark through the normalizer
|
||||
and commit its output —
|
||||
```bash
|
||||
scripts/normalize-app-icon.py your-mark.svg apps-icon-output.svg
|
||||
```
|
||||
It wraps any SVG (full-bleed upstream logos included) onto the house
|
||||
canvas with the standard margin, preserving the original artwork
|
||||
untouched inside.
|
||||
- Provide a URL to your app icon (PNG, WebP, or SVG)
|
||||
- Recommended size: 256x256 pixels
|
||||
- Square aspect ratio
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.1-alpha",
|
||||
"version": "1.8.3-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.1-alpha",
|
||||
"version": "1.8.3-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.1-alpha",
|
||||
"version": "1.8.3-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 100 100">
|
||||
<!-- normalized by scripts/normalize-app-icon.py: margin=0.12 per side -->
|
||||
<svg x="12.000" y="12.000" width="76.000" height="76.000" viewBox="0 0 8000 8000" preserveAspectRatio="xMidYMid meet">
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8000 8000">
|
||||
<path d="M1404.94034,2404.75143c.19948.39893.416.79745.63896,1.19934-.21965-.3998-.4293-.7996-.63896-1.19934ZM1398.36987,2392.55825c.01.02001.01999.03996.01999.04996.01999.02001.03998.04996.04998.06997-.01999-.03996-.04998-.07997-.06997-.11993ZM7957.22848,6407.80899c-75.3022-453.82005-341.31246-1051.68618-631.76608-1536.83905-217.58172,280.41299-443.70125,543.12568-695.66263,796.78364,151.96083,272.22676,381.52012,831.33939,371.78586,1131.07073-3.34955,103.13742-84.59871,186.38404-187.48323,193.03587-323.69068,20.92769-761.15088-169.80941-1126.96417-352.01125-274.08918,250.36976-539.73074,478.97192-815.02811,690.20321,570.85548,338.56749,1305.54247,669.74202,1904.46573,668.08293,872.77622,46.26671,1378.68446-776.20339,1180.65263-1590.32607ZM5249.15884,5683.56479c1101.91285-1101.01171,2353.03599-2527.31198,2708.11113-4090.5697,253.93721-1273.80049-760.34667-1869.98142-1895.16006-1469.66567,211.65045,278.23428,364.22738,589.33979,457.73054,914.98744,86.80988-20.20216,196.93112-34.80914,289.99476-31.50156,103.33929,3.67279,186.93541,86.38156,191.60732,189.77353,13.96581,309.07109-235.73659,902.8025-402.1382,1193.8406,0,.01-.01.02001-.01.03001l-.04998.07991c-624.44753,1143.09071-1591.89389,2208.42396-2600.29743,3077.06633l-.22964-.19984c-263.64443,226.46298-526.16056,433.98687-804.2439,626.56893.39938.34966.79882.69956,1.19826,1.04946-509.31622,353.04862-1419.46673,925.33606-2012.13405,893.44541-103.82534-5.58671-186.23795-90.1864-188.67838-194.22674-6.97909-297.53161,215.6413-843.71786,363.83869-1111.11806-237.05325-259.94423-464.71032-529.69327-686.31643-814.38364-1228.33988,1900.32534-772.83666,3911.20697,1772.96127,2814.86279,1004.95316-454.63593,1968.00538-1208.05697,2803.81609-2000.0392ZM2528.2177,4000.38756c-468.17105-551.20508-806.89093-1020.88409-1125.16461-1599.13417h-.01c-.00197-.02709-.02111-.04517-.02999-.07997h-.01c-.00225-.03154-.04191-.05163-.03986-.08992,0,0-.01,0-.01-.01-1.46642-2.7001-3.08021-5.72568-4.5634-8.46529.01999.02001.03998.04996.04998.06997-165.01013-287.23702-415.64692-882.63022-403.53149-1193.46936,4.07951-104.66591,88.1281-188.31126,192.71288-192.04153,92.1143-3.28547,200.69379,10.46969,286.62767,30.61985,93.58312-325.69743,246.26999-636.85321,458.06014-915.09749C797.66386-275.84563-214.1867,319.25478,39.32792,1592.99819c208.75964,1126.24494,1140.55524,2354.14531,1865.30175,3206.20191-1.24817,1.76903,1.23818-1.76903,0,0,177.91975,202.67638,363.17881,406.52227,558.17368,604.08153,267.29912-191.50259,530.82373-396.10806,795.10723-617.7438-259.10108-257.46588-503.73334-520.6384-729.69288-785.15026ZM1420.59738,2433.86513c.84886,1.56913,1.71746,3.17821,2.5963,4.82725-.85885-1.58908-1.72745-3.19816-2.5963-4.82725ZM1457.40366,2502.26682c.92857,1.699,1.83716,3.39806,2.73599,5.07711-.89883-1.64904-1.80742-3.3481-2.73599-5.07711ZM1403.01311,2401.17342c-.01-.01-.01987-.02995-.02986-.03996.01.01.01987.02995.01987.03996.01999.03001.02999.04996.03998.06997-.01-.02001-.01999-.03996-.02999-.06997ZM1404.94034,2404.75143c.19948.39893.416.79745.63896,1.19934-.21965-.3998-.4293-.7996-.63896-1.19934Z" fill="#ffe480"/>
|
||||
<path d="M2762.56314,2987.43935c-683.41752-683.42045-683.41752-1791.46134,0-2474.87781,683.41752-683.41755,1791.45841-683.41755,2474.87885,0,683.41068,683.41648,683.41068,1791.45737,0,2474.87781l-1237.44089,1237.44089-1237.43796-1237.44089Z" fill="#fff"/>
|
||||
</svg>
|
||||
</svg>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,7 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 100 100">
|
||||
<!-- normalized by scripts/normalize-app-icon.py: margin=0.12 per side -->
|
||||
<svg x="12.000" y="12.000" width="76.000" height="76.000" viewBox="0 0 94 94" preserveAspectRatio="xMidYMid meet">
|
||||
<svg viewBox="0 0 94 94" id="vector" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="group" transform="matrix(1, 0, 0, 1, -6.999915, -7)">
|
||||
<path id="path" d="M 30 81 C 23 80 21.63 71.49 29.16 45.87 C 35 26 46.18 26 58 26 C 73.17 26 81.61 29.18 83.79 35.73 C 84.243 36.883 84.39 38.135 84.214 39.362 C 84.039 40.589 83.548 41.75 82.79 42.73 C 82.613 42.96 82.419 43.178 82.21 43.38 C 85.1 45.31 86.88 48.02 86.52 51.9 C 86.13 56.26 83.16 58.55 78.52 59.66 C 78.849 60.396 79.013 61.194 79 62 C 78.988 63.199 78.695 64.378 78.147 65.444 C 77.598 66.51 76.808 67.433 75.84 68.14 C 70.09 72.76 60 75 46 73 C 43 72.57 41 75 39 77 C 36.62 79.38 34.24 81.61 30 81 Z" fill="#25A5FF"/>
|
||||
<path id="path_1" d="M 58 30 C 66 30 78 31 80 37 C 82.24 43.71 69 43 59 42 C 68 43 83.18 44 82.5 51.5 C 82 57 72 57 55 56 C 63 57 75 58 75 62 C 75 68.28 58.74 71 47.67 69.31 C 39 68 38.06 76.77 31 75 C 27 74 28 63 33 47 C 38.29 30.09 46.49 30 58 30 Z" fill="#ffffff" stroke="#25A5FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,39 +1,5 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<!-- KAMMERGUT GLOSS TEST (2026-08-12): SVG paint filter lifted verbatim
|
||||
from plan-b's Kammergut wordmark (#paintGloss). Consumed by the
|
||||
.logo-gradient-border::after override in style.css. Revert by
|
||||
deleting this svg block + that css block (one commit). -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<filter id="paintGloss" x="-12%" y="-30%" width="124%" height="160%" color-interpolation-filters="sRGB">
|
||||
<feMorphology in="SourceAlpha" operator="dilate" radius="2.5" result="dilated"/>
|
||||
<feGaussianBlur in="dilated" stdDeviation="6" result="puddle"/>
|
||||
<feFlood flood-color="#fff4dc" flood-opacity="0.85" result="puddleColor"/>
|
||||
<feComposite in="puddleColor" in2="puddle" operator="in" result="puddleHi"/>
|
||||
<feFlood flood-color="#1A1A18" flood-opacity="0.18" result="puddleShadowColor"/>
|
||||
<feComposite in="puddleShadowColor" in2="puddle" operator="in" result="puddleShadow"/>
|
||||
<feOffset in="puddleShadow" dx="0" dy="3" result="puddleShadowOff"/>
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="1.4" result="bump"/>
|
||||
<feSpecularLighting in="bump" surfaceScale="4" specularConstant="0.9"
|
||||
specularExponent="40" lighting-color="#fff2d4" result="spec2">
|
||||
<fePointLight x="600" y="-200" z="380"/>
|
||||
</feSpecularLighting>
|
||||
<feComposite in="spec2" in2="SourceAlpha" operator="in" result="specClip2"/>
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="4" result="dsBlur"/>
|
||||
<feOffset in="dsBlur" dx="0" dy="6" result="dsOff"/>
|
||||
<feComponentTransfer in="dsOff" result="dsFinal">
|
||||
<feFuncA type="linear" slope="0.45"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode in="dsFinal"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
<feMergeNode in="specClip2"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<!-- Splash Screen (only on first visit) -->
|
||||
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
|
||||
|
||||
|
||||
@@ -21,11 +21,25 @@
|
||||
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.memoOptional') }}</label>
|
||||
<input v-model="invoiceMemo" type="text" :placeholder="t('receiveBitcoin.memoPlaceholder')" class="w-full input-glass" />
|
||||
</div>
|
||||
<div v-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
||||
<!-- Paid: the invoice did its job — straight to the green check
|
||||
(no broadcast step: Lightning settlement is final) -->
|
||||
<div v-if="invoicePaid" class="mb-3 p-6 bg-white/5 rounded-lg text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-16 h-16 rounded-full flex items-center justify-center bg-green-500/15">
|
||||
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white mb-1">{{ t('receiveBitcoin.paymentConfirmed') }}</p>
|
||||
<p v-if="invoicePaid.amountSats > 0" class="text-2xl font-semibold text-white/95 mb-2">
|
||||
{{ invoicePaid.amountSats.toLocaleString() }} sats
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
||||
<canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
||||
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.invoiceShareLabel') }}</p>
|
||||
<p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p>
|
||||
<CopyButton :value="invoiceResult" :label="t('common.copy')" class="mt-2" />
|
||||
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.invoiceShareLabel') }}</p>
|
||||
<CopyButton :value="invoiceResult" :label="t('common.copy')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,6 +161,9 @@ watch(() => props.show, (open) => {
|
||||
return
|
||||
}
|
||||
paymentSeen.value = null
|
||||
invoicePaid.value = null
|
||||
invoiceRHash.value = ''
|
||||
stopWatchingInvoice()
|
||||
// Blank slate on every open: a leftover amount/memo/token or a previous
|
||||
// invoice quietly carrying into a new receive flow is exactly the stale-
|
||||
// state class the operator flagged on the send modal (2026-08-05).
|
||||
@@ -238,6 +255,46 @@ async function checkForPayment() {
|
||||
|
||||
onUnmounted(stopWatchingPayment)
|
||||
|
||||
// Lightning settlement watch — the on-chain pattern minus the broadcast
|
||||
// step: an invoice is either open or SETTLED (final), so the success view
|
||||
// goes straight to the green check.
|
||||
const invoicePaid = ref<null | { amountSats: number }>(null)
|
||||
const invoiceRHash = ref('')
|
||||
let invoiceTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function stopWatchingInvoice() {
|
||||
if (invoiceTimer) {
|
||||
clearInterval(invoiceTimer)
|
||||
invoiceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startWatchingInvoice() {
|
||||
stopWatchingInvoice()
|
||||
invoiceTimer = setInterval(() => void checkInvoice(), 3000)
|
||||
}
|
||||
|
||||
async function checkInvoice() {
|
||||
if (!props.show || !invoiceRHash.value) {
|
||||
stopWatchingInvoice()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await rpcClient.call<{ settled: boolean; amt_paid_sat: number }>({
|
||||
method: 'lnd.invoicestatus',
|
||||
params: { r_hash_hex: invoiceRHash.value },
|
||||
})
|
||||
if (!res.settled) return
|
||||
invoicePaid.value = { amountSats: res.amt_paid_sat || invoiceAmount.value }
|
||||
stopWatchingInvoice()
|
||||
emit('received')
|
||||
} catch {
|
||||
// Transient poll failure — keep watching.
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stopWatchingInvoice)
|
||||
|
||||
async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix = '') {
|
||||
if (!canvas || !data) return
|
||||
try {
|
||||
@@ -272,11 +329,14 @@ async function receive() {
|
||||
// lnd.createinvoice fail with connection-refused (FED-08 follow-up).
|
||||
if (!(await lightning.requireLightningReady('receive'))) return
|
||||
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
|
||||
const res = await rpcClient.call<{ payment_request: string }>({
|
||||
const res = await rpcClient.call<{ payment_request: string; r_hash_hex?: string }>({
|
||||
method: 'lnd.createinvoice',
|
||||
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
|
||||
})
|
||||
invoiceResult.value = res.payment_request
|
||||
invoiceRHash.value = res.r_hash_hex || ''
|
||||
invoicePaid.value = null
|
||||
if (invoiceRHash.value) startWatchingInvoice()
|
||||
nextTick(() => renderQr(res.payment_request, lightningQrCanvas.value, 'lightning:'))
|
||||
} else if (receiveMethod.value === 'onchain') {
|
||||
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="logo-gradient-border screensaver-logo-cycle relative w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80 flex items-center justify-center overflow-hidden">
|
||||
<div class="logo-gradient-border logo-gloss screensaver-logo-cycle relative w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80 flex items-center justify-center overflow-hidden">
|
||||
<!-- Squares logo -->
|
||||
<div class="screensaver-logo-squares absolute inset-[3px] flex items-center justify-center">
|
||||
<AnimatedLogo size="xl" no-border fit />
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
<canvas ref="tokenQrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
|
||||
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
|
||||
<CopyButton :value="ecashToken" :label="t('common.copy')" size="sm" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
||||
@@ -604,10 +604,6 @@ function sendAnother() {
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
}
|
||||
|
||||
const tokenQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
watch(ecashToken, async (token) => {
|
||||
if (!token) return
|
||||
|
||||
@@ -202,11 +202,7 @@
|
||||
</div>
|
||||
<div v-if="arkAddress" class="mt-2 flex items-center gap-2 p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-xs font-mono text-white/90 break-all flex-1">{{ arkAddress }}</span>
|
||||
<button @click="copyArkAddress" class="p-2 rounded-lg hover:bg-white/10 text-white/50 hover:text-white shrink-0" :title="arkCopied ? 'Copied' : 'Copy'">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<CopyButton :value="arkAddress" icon-only class="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -277,6 +273,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CopyButton from '@/components/CopyButton.vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
@@ -349,7 +346,6 @@ const arkStatus = ref<ArkStatus | null>(null)
|
||||
const arkBalance = ref<ArkBalance | null>(null)
|
||||
const arkConfig = ref({ network: 'signet', ark_server: '', esplora: '' })
|
||||
const arkAddress = ref('')
|
||||
const arkCopied = ref(false)
|
||||
const loadingArk = ref(false)
|
||||
const savingArk = ref(false)
|
||||
const arkBoarding = ref(false)
|
||||
@@ -505,22 +501,11 @@ async function fetchArkAddress(onchain: boolean) {
|
||||
params: { onchain },
|
||||
})
|
||||
arkAddress.value = res.address
|
||||
arkCopied.value = false
|
||||
} catch (err: unknown) {
|
||||
arkError.value = err instanceof Error ? err.message : 'Failed to get address'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyArkAddress() {
|
||||
if (!arkAddress.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(arkAddress.value)
|
||||
arkCopied.value = true
|
||||
} catch {
|
||||
/* clipboard unavailable (http) — the address is selectable */
|
||||
}
|
||||
}
|
||||
|
||||
async function boardArk() {
|
||||
arkBoarding.value = true
|
||||
arkError.value = ''
|
||||
|
||||
@@ -702,11 +702,22 @@ function applyGraph() {
|
||||
const reqs = [...(props.requests ?? [])].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
const reqRing = requestRingRadius(peerNodes.length)
|
||||
const seenReqs = new Set<string>()
|
||||
// Requests are a call to action: cluster them tightly at one stage angle
|
||||
// (instead of spread around the orbit, where they could sit BEHIND the
|
||||
// globe — reading as a mis-scaled chart the user had to rotate by hand)
|
||||
// and steer the camera to face them when a new one arrives.
|
||||
const REQUEST_STAGE_ANGLE = 0.35
|
||||
const reqSpacing = Math.min(0.5, (Math.PI * 0.9) / Math.max(reqs.length, 1))
|
||||
let newRequestArrived = false
|
||||
reqs.forEach((req, i) => {
|
||||
seenReqs.add(req.id)
|
||||
const slot = { angle: (i / reqs.length) * Math.PI * 2 + 0.35, ringRadius: reqRing }
|
||||
const slot = {
|
||||
angle: REQUEST_STAGE_ANGLE + (i - (reqs.length - 1) / 2) * reqSpacing,
|
||||
ringRadius: reqRing,
|
||||
}
|
||||
const existing = reqMap.get(req.id)
|
||||
if (!existing) {
|
||||
newRequestArrived = true
|
||||
const vis = createRequest(req, slot)
|
||||
if (introPlayed) {
|
||||
if (staticMode) vis.p = 1
|
||||
@@ -738,6 +749,20 @@ function applyGraph() {
|
||||
for (const [id, vis] of reqMap) {
|
||||
if (!seenReqs.has(id)) killRequest(vis)
|
||||
}
|
||||
// Face the request cluster when a new one arrives. Depth for a node at
|
||||
// world angle a is proportional to sin(a − rotY): front-center (nearest
|
||||
// to the viewer, horizontally centred) is a − rotY = −π/2. Only on
|
||||
// ARRIVAL, so a user who rotates away afterwards isn't fought.
|
||||
if (newRequestArrived && introPlayed) {
|
||||
const target = nearestAngle(cam.rotY, REQUEST_STAGE_ANGLE + Math.PI / 2)
|
||||
for (const t of spinTween) t.kill()
|
||||
if (staticMode && !tickerAttached) {
|
||||
cam.rotY = target
|
||||
render()
|
||||
} else {
|
||||
gsap.to(cam, { rotY: target, duration: 1.1, ease: motionTokens.ease.inOut, overwrite: 'auto' })
|
||||
}
|
||||
}
|
||||
|
||||
// --- orbit guide rings ---
|
||||
const desiredRings: { radius: number; kind: 'peer' | 'request' }[] = []
|
||||
@@ -878,7 +903,9 @@ function tick(_time: number, deltaMS: number) {
|
||||
}
|
||||
|
||||
function attachTicker() {
|
||||
if (tickerAttached || staticMode) return
|
||||
// Kiosks run staticMode for PLACEMENT (instant, intro-free) but still get
|
||||
// the calm orbit — half-rate via the kioskLowPower frame-skip in tick().
|
||||
if (tickerAttached || (staticMode && !kioskLowPower)) return
|
||||
gsap.ticker.add(tick)
|
||||
tickerAttached = true
|
||||
}
|
||||
@@ -1179,10 +1206,20 @@ function measure() {
|
||||
}
|
||||
// Resize: snap to the current mode's fit (no tween — tracks the drag)
|
||||
applyMode(viewMode.value, false)
|
||||
// No ticker (reduced-motion) means nobody repaints after this resize —
|
||||
// without an explicit render the map keeps its stale (possibly zero-size)
|
||||
// projection: the "loads blank / wrong scale until refresh" bug.
|
||||
if (built && !tickerAttached) render()
|
||||
}
|
||||
|
||||
function initialBuild() {
|
||||
staticMode = prefersReducedMotion()
|
||||
// Kiosk TVs take the static PLACEMENT path (the GSAP entrance intro needs
|
||||
// healthy rAF delivery to reach opacity 1 — on a paint-starved kiosk it
|
||||
// stalls and the screen reads as BLANK until a lucky refresh,
|
||||
// framework-pt 2026-08-14) but keep the calm orbital motion: the ticker
|
||||
// re-attaches below at half rate. Fully-static-everything was tried and
|
||||
// rejected — it also killed the animation on screens that could carry it.
|
||||
staticMode = prefersReducedMotion() || kioskLowPower
|
||||
built = true
|
||||
applyGraph()
|
||||
|
||||
@@ -1200,6 +1237,8 @@ function initialBuild() {
|
||||
if (staticMode) {
|
||||
introPlayed = true
|
||||
playIntro() // static branch: everything lands in place
|
||||
attachTicker() // no-op for reduced-motion; kiosks resume the calm orbit
|
||||
render() // paint the landed state now — nothing else repaints until a tick
|
||||
return
|
||||
}
|
||||
attachTicker()
|
||||
|
||||
+52
-8
@@ -1128,14 +1128,58 @@ html.controller-nav [data-controller-container]:focus {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* KAMMERGUT GLOSS TEST (2026-08-12) — black gloss paint on the badge's
|
||||
inner circle, lifted from plan-b's Kammergut wordmark (.paint-3d
|
||||
gradient + #paintGloss filter, defs injected in App.vue). Revert:
|
||||
delete this block + the svg defs block in App.vue (one commit). */
|
||||
.logo-gradient-border::after {
|
||||
background: linear-gradient(180deg, #2a2a26 0%, #1a1a18 45%, #060604 100%);
|
||||
filter: url(#paintGloss);
|
||||
-webkit-filter: url(#paintGloss);
|
||||
/* KAMMERGUT GLOSS v3 TEST (2026-08-14, UNCOMMITTED until approved) —
|
||||
wet black paint, PURE CSS (the SVG text filter embosses badly on a
|
||||
disc; headless A/B renders picked this). Borderless per operator:
|
||||
the painted disc IS the badge. Layers: warm ambient top sheen, wet
|
||||
lower reflection arc, bottom depth vignette, paint-black base; inset
|
||||
rim lights; and a curved liquid "window streak" on ::before (under
|
||||
the badge's z-1 content, above the paint). Revert: delete this block. */
|
||||
.logo-gradient-border.logo-gloss,
|
||||
.tap-to-start-logo .logo-gradient-border {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.logo-gradient-border.logo-gloss::after,
|
||||
.tap-to-start-logo .logo-gradient-border::after {
|
||||
inset: 0;
|
||||
/* Dense intermediate stops + a fine turbulence grain (top layer,
|
||||
overlay-blended) dither the near-black falloffs — without them the
|
||||
big disc shows visible banding rings. */
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E"),
|
||||
radial-gradient(115% 60% at 50% -10%, rgba(255,244,220,0.22) 0%, rgba(255,244,220,0.14) 18%, rgba(255,244,220,0.08) 32%, rgba(255,244,220,0.04) 43%, rgba(255,244,220,0.015) 52%, rgba(255,244,220,0) 62%),
|
||||
radial-gradient(90% 34% at 50% 108%, rgba(255,238,200,0.12) 0%, rgba(255,238,200,0.07) 30%, rgba(255,238,200,0.03) 48%, rgba(255,238,200,0) 68%),
|
||||
radial-gradient(140% 120% at 50% 135%, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.62) 22%, rgba(0,0,0,0.36) 36%, rgba(0,0,0,0.16) 46%, rgba(0,0,0,0.05) 52%, rgba(0,0,0,0) 58%),
|
||||
linear-gradient(180deg, #26261f 0%, #20201b 22%, #141412 44%, #0b0b09 68%, #040403 100%);
|
||||
background-blend-mode: overlay, normal, normal, normal, normal;
|
||||
box-shadow:
|
||||
inset 0 2px 2px rgba(255,246,224,0.42),
|
||||
inset 0 -1px 1px rgba(255,238,200,0.16),
|
||||
inset 0 -10px 22px rgba(0,0,0,0.82),
|
||||
inset 0 0 0 1px rgba(255,242,212,0.08),
|
||||
0 12px 34px rgba(0,0,0,0.65);
|
||||
}
|
||||
.logo-gradient-border.logo-gloss::before,
|
||||
.tap-to-start-logo .logo-gradient-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8%;
|
||||
right: 8%;
|
||||
top: 3%;
|
||||
height: 46%;
|
||||
z-index: 1;
|
||||
border-radius: 50% / 62%;
|
||||
/* Radial (2D) falloff, not linear: a linear streak is equally bright
|
||||
across its full width, so its left/right TIPS end in a hard seam
|
||||
against the rim (operator screenshot, 2026-08-14). The ellipse
|
||||
fades toward every edge — no tips, no seam, and the lower edge
|
||||
dissolves long before the clipped shape ends. */
|
||||
background: radial-gradient(75% 105% at 50% -6%, rgba(255,250,236,0.55) 0%, rgba(255,246,224,0.30) 34%, rgba(255,246,224,0.12) 55%, rgba(255,244,220,0.04) 72%, rgba(255,244,220,0) 86%);
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logo-gradient-border img,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Auto-tab memory — apps observed to refuse iframe embedding.
|
||||
*
|
||||
* The gate strips frame-blocking headers for gated apps (1.8.1+), but three
|
||||
* failure modes survive any header fix: JS frame-busting, apps that must be
|
||||
* the top-level origin (OAuth/WebAuthn), and SameSite=Strict session cookies.
|
||||
* Cross-origin embed failure is not reliably detectable up front from the
|
||||
* browser, so the app session's load-timeout is the detector — and this
|
||||
* module is the memory: once an app is seen blocked while RUNNING, it is
|
||||
* remembered here and every later launch opens it as a tab app directly from
|
||||
* the click (a user gesture, so never popup-blocked). The dead grey pane
|
||||
* happens at most once per app.
|
||||
*
|
||||
* Entries expire after 7 days so a node update that fixes embedding (for
|
||||
* example a gate improvement) gets re-probed instead of being remembered as
|
||||
* broken forever; a successful iframe load also clears the entry immediately.
|
||||
*/
|
||||
|
||||
const KEY = 'archipelago_auto_new_tab_apps'
|
||||
const EXPIRY_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
function read(): Record<string, number> {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(KEY) || '{}')
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw
|
||||
} catch { /* corrupt/unavailable storage reads as empty */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
function write(entries: Record<string, number>) {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(entries)) } catch { /* full/denied */ }
|
||||
}
|
||||
|
||||
export function isAutoTabApp(id: string): boolean {
|
||||
const at = read()[id]
|
||||
return typeof at === 'number' && Date.now() - at < EXPIRY_MS
|
||||
}
|
||||
|
||||
export function rememberAutoTabApp(id: string): void {
|
||||
const entries = read()
|
||||
entries[id] = Date.now()
|
||||
write(entries)
|
||||
}
|
||||
|
||||
export function forgetAutoTabApp(id: string): void {
|
||||
const entries = read()
|
||||
if (id in entries) {
|
||||
delete entries[id]
|
||||
write(entries)
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import { isAutoTabApp, rememberAutoTabApp, forgetAutoTabApp } from '@/utils/autoTabApps'
|
||||
import AppSessionHeader from './appSession/AppSessionHeader.vue'
|
||||
import AppSessionFrame from './appSession/AppSessionFrame.vue'
|
||||
import MobileGamepad from './appSession/MobileGamepad.vue'
|
||||
@@ -199,9 +200,27 @@ function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
|
||||
// same-origin by the mock backend, so the prod new-tab list doesn't apply.
|
||||
const mustOpenNewTab = computed(() =>
|
||||
(NEW_TAB_APPS.has(appId.value) && !(IS_DEMO && isDemoApp(appId.value))) ||
|
||||
// Remembered embed-refusers (frame-busting, top-level-origin apps): the
|
||||
// first blocked encounter records them, every later launch is a tab app.
|
||||
isAutoTabApp(appId.value) ||
|
||||
(IS_DEMO && isDemoExternal(appId.value))
|
||||
)
|
||||
|
||||
// The auto-tab detector: the load-timeout marked the frame blocked, the
|
||||
// warming-up retry loop has given up, and the backend says the app is
|
||||
// actually RUNNING — that combination is the embed-refusal signature (a
|
||||
// down app is "warming up" or shows a blocked reason instead). Remember it
|
||||
// so this app never shows the dead pane again; a later successful iframe
|
||||
// load (onLoad) clears the memory, and entries expire on their own.
|
||||
watch([iframeBlocked, warmingUp], ([blocked, warming]) => {
|
||||
if (
|
||||
blocked && !warming && !blockedReason.value &&
|
||||
(packageEntry.value?.state === PackageState.Running)
|
||||
) {
|
||||
rememberAutoTabApp(appId.value)
|
||||
}
|
||||
})
|
||||
|
||||
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
||||
// serves clients once its index is built). Poll /electrs-status while this is
|
||||
// the Electrum app; pass the status to the frame only while still syncing.
|
||||
@@ -344,6 +363,10 @@ function onLoad() {
|
||||
loading.value = false
|
||||
isRefreshing.value = false
|
||||
autoRetryCount.value = 0
|
||||
// A frame that loads embeds fine — heal any stale auto-tab memory (for
|
||||
// example an app remembered as blocked before the gate learned to strip
|
||||
// frame headers).
|
||||
forgetAutoTabApp(appId.value)
|
||||
// TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's
|
||||
// virtual keyboard) flow into the iframe without needing a pointer click.
|
||||
try { frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<!-- Logo - half in, half out of container -->
|
||||
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10">
|
||||
<div class="logo-gradient-border w-20 h-20">
|
||||
<div class="logo-gradient-border logo-gloss w-20 h-20">
|
||||
<AnimatedLogo no-border fit />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0 archy-app-icon"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-20 h-20 rounded-xl bg-white/10 flex items-center justify-center flex-shrink-0">
|
||||
@@ -99,7 +99,7 @@
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl"
|
||||
class="w-20 h-20 rounded-xl shadow-xl archy-app-icon"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-20 h-20 rounded-xl bg-white/10 flex items-center justify-center">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="glass-card p-8 pt-16 sm:p-12 sm:pt-20 text-center relative overflow-visible onb-card">
|
||||
<!-- Logo - half in, half out of container -->
|
||||
<div class="absolute -top-8 sm:-top-10 left-0 right-0 flex justify-center z-10 onb-logo">
|
||||
<div class="logo-gradient-border w-16 h-16 sm:w-20 sm:h-20">
|
||||
<div class="logo-gradient-border logo-gloss w-16 h-16 sm:w-20 sm:h-20">
|
||||
<AnimatedLogo no-border fit />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
import { isAutoTabApp } from '@/utils/autoTabApps'
|
||||
|
||||
export type AppsTab = 'apps' | 'websites' | 'services'
|
||||
|
||||
@@ -173,7 +174,10 @@ export const TAB_LAUNCH_APPS = new Set([
|
||||
])
|
||||
|
||||
export function opensInTab(id: string): boolean {
|
||||
return TAB_LAUNCH_APPS.has(id)
|
||||
// The launch icon and launch behavior follow the dynamic verdict too:
|
||||
// apps observed refusing the iframe (remembered by the app session) show
|
||||
// the same tab-launch affordance as the statically-known tab apps.
|
||||
return TAB_LAUNCH_APPS.has(id) || isAutoTabApp(id)
|
||||
}
|
||||
|
||||
// Backend services that ship no icon of their own reuse their PARENT app's icon
|
||||
|
||||
@@ -362,6 +362,30 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.3-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.8.3-alpha</span>
|
||||
<span class="text-xs text-white/40">August 14, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>**The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.</p>
|
||||
<p>**The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.</p>
|
||||
<p>**App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.2-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.8.2-alpha</span>
|
||||
<span class="text-xs text-white/40">August 13, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>**An app that can't be shown inside the dashboard now becomes a tab app by itself.** A few apps refuse to render inside another page no matter what — they break out with their own code or insist on owning the whole browser window. Opening one used to mean staring at a grey pane. Now the dashboard notices, offers the app in its own tab, and remembers: from then on that app's button opens a tab directly (with the little launch icon that tab apps carry), first click, every time. If a later update makes the app embeddable after all, the dashboard notices that too and goes back to embedding it.</p>
|
||||
<p>**The logo emblem got its glossy black paint finish — properly this time.** The circle behind the A on the screensaver, intro, and login now wears a deep wet-paint look: warm light blooming from the top edge, fine grain so the dark tones stay smooth instead of banding, and no more ring border. (An earlier rougher version of this experiment briefly shipped by accident and then vanished depending on which screen you were on — this is the finished, deliberate one, everywhere.)</p>
|
||||
<p>**New app icons now match the store's look, on every screen.** Alby Hub and phoenixd arrived with edge-to-edge logos that ignored the breathing room every other app icon has, and the app detail page skipped the icon backdrop entirely. Both icons are re-set on the standard canvas, the detail page now applies the same icon treatment as the store tiles, and app developers get a one-command tool that puts any logo onto the house canvas automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.1-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -26,7 +26,30 @@ async function computeFingerprint(pem: string): Promise<string> {
|
||||
.join(':')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const generating = ref(false)
|
||||
const generateError = ref('')
|
||||
|
||||
// WebUI rule: never point a user at a terminal — the backend runs the
|
||||
// (idempotent) CA setup script for us.
|
||||
async function generateCa() {
|
||||
generating.value = true
|
||||
generateError.value = ''
|
||||
try {
|
||||
const { rpcClient } = await import('@/api/rpc-client')
|
||||
await rpcClient.call({ method: 'system.node-ca.generate', timeout: 60000 })
|
||||
loading.value = true
|
||||
await probe()
|
||||
if (!caAvailable.value) {
|
||||
generateError.value = 'Generation reported success but the certificate is not being served yet — try reloading in a few seconds.'
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
generateError.value = e instanceof Error ? e.message : 'Certificate generation failed'
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function probe() {
|
||||
try {
|
||||
const res = await fetch('/ca.crt', { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
@@ -48,7 +71,9 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(probe)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -70,9 +95,13 @@ onMounted(async () => {
|
||||
v-else-if="!caAvailable"
|
||||
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
|
||||
>
|
||||
This node has not generated a certificate authority yet. Run
|
||||
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">scripts/setup-node-ca.sh</code>
|
||||
on the node, then reload this page.
|
||||
<p class="mb-3">This node has not generated its certificate yet.</p>
|
||||
<button
|
||||
:disabled="generating"
|
||||
@click="generateCa"
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-semibold disabled:opacity-60"
|
||||
>{{ generating ? 'Generating…' : 'Generate certificate' }}</button>
|
||||
<p v-if="generateError" class="mt-2 text-xs text-orange-300/90">{{ generateError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
// Routstr prepaid budget (D-05): the assistant may only spend against an
|
||||
// operator-set sats ceiling. Zero allowance = Routstr never selected. This
|
||||
// panel is the ONLY UI for that ceiling; it fronts assistant.budget-get/set.
|
||||
const loaded = ref(false)
|
||||
const allowance = ref(0)
|
||||
const spent = ref(0)
|
||||
const remaining = ref(0)
|
||||
const draft = ref('')
|
||||
const applying = ref(false)
|
||||
const error = ref('')
|
||||
const savedTick = ref(false)
|
||||
|
||||
const active = computed(() => allowance.value > 0)
|
||||
|
||||
async function refresh() {
|
||||
const res = await rpcClient.call<{ allowance_sats: number; spent_sats: number; remaining_sats: number }>({
|
||||
method: 'assistant.budget-get',
|
||||
})
|
||||
allowance.value = res.allowance_sats
|
||||
spent.value = res.spent_sats
|
||||
remaining.value = res.remaining_sats
|
||||
draft.value = String(res.allowance_sats)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await refresh()
|
||||
loaded.value = true
|
||||
} catch { /* backend without the RPC — section hides */ }
|
||||
})
|
||||
|
||||
async function apply() {
|
||||
const parsed = Number(draft.value)
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
error.value = 'Enter a whole number of sats (0 disables Routstr).'
|
||||
return
|
||||
}
|
||||
applying.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await rpcClient.call({ method: 'assistant.budget-set', params: { allowance_sats: parsed } })
|
||||
await refresh()
|
||||
savedTick.value = true
|
||||
setTimeout(() => { savedTick.value = false }, 2500)
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to save budget'
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loaded" class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h2 class="text-xl font-semibold text-white/96">Routstr AI Budget</h2>
|
||||
<span
|
||||
class="text-xs font-mono px-2 py-0.5 rounded"
|
||||
:class="active ? 'bg-emerald-500/20 text-emerald-300' : 'bg-white/10 text-white/50'"
|
||||
>{{ active ? 'enabled' : 'off' }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/60 mb-5">
|
||||
Routstr is pay-per-use AI inference, paid in sats over Cashu, used when your local
|
||||
model can't take a request. It never spends without a prepaid ceiling you set here —
|
||||
at <span class="font-mono">0</span> it is completely disabled. The assistant stops
|
||||
when the ceiling is reached; raising it widens the remainder without erasing the
|
||||
spend history.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 mb-5 text-center">
|
||||
<div class="rounded-lg bg-white/5 py-3">
|
||||
<div class="text-lg font-mono text-white/90">{{ allowance.toLocaleString() }}</div>
|
||||
<div class="text-xs text-white/50 mt-1">Allowance (sats)</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white/5 py-3">
|
||||
<div class="text-lg font-mono text-white/90">{{ spent.toLocaleString() }}</div>
|
||||
<div class="text-xs text-white/50 mt-1">Spent</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white/5 py-3">
|
||||
<div class="text-lg font-mono" :class="remaining > 0 ? 'text-emerald-300' : 'text-white/60'">{{ remaining.toLocaleString() }}</div>
|
||||
<div class="text-xs text-white/50 mt-1">Remaining</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1 max-w-xs">
|
||||
<label class="block text-sm text-white/60 mb-1" for="routstr-allowance">Set allowance (sats)</label>
|
||||
<input
|
||||
id="routstr-allowance"
|
||||
v-model="draft"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white font-mono placeholder-white/30 focus:outline-none focus:border-white/30 transition-colors"
|
||||
@keydown.enter="apply"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
:disabled="applying || draft === String(allowance)"
|
||||
@click="apply"
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>{{ applying ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="savedTick" class="text-sm text-emerald-300 pb-2">Saved</span>
|
||||
</div>
|
||||
<div v-if="error" class="mt-3 alert-error text-sm">{{ error }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
|
||||
import KioskDisplaySection from '@/views/settings/KioskDisplaySection.vue'
|
||||
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
|
||||
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
|
||||
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
|
||||
import WebhookSection from '@/views/settings/WebhookSection.vue'
|
||||
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
||||
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
|
||||
@@ -15,6 +16,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||
<InterfaceModeSection />
|
||||
<KioskDisplaySection />
|
||||
<ClaudeAuthSection />
|
||||
<RoutstrBudgetSection />
|
||||
<AIDataAccessSection />
|
||||
<WebhookSection />
|
||||
<TelemetrySection />
|
||||
|
||||
+17
-17
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Apps that refused to open inside the dashboard now embed like everything else.** Some apps ship browser headers that forbid being shown inside another page — correct hardening on the open web, but inside Archipelago it produced a dead grey pane when you opened them from My Apps (Alby Hub was the first to hit it). The app gate, which already checks your login on every request to an app, now removes just those framing headers on the way through; each app's own content-security rules pass through untouched. No more per-app proxy workarounds.",
|
||||
"**The network map no longer freezes kiosk TVs.** The animated federation map at 4K was too much for the deliberately conservative graphics settings the on-screen display used on every machine — settings chosen years back to stop audio crackle on much older hardware. Two fixes: on kiosk screens the map now opens in its flat 2D view (the 3D globe is one tap away, and remembered) and animates at half rate — invisible from the couch, half the work. And the display itself now recognizes what machine it runs on: older kiosk boxes keep the proven careful settings, modern ones finally get real GPU rendering.",
|
||||
"**New Settings → Display → Graphics choice for the on-screen display.** Auto (recommended) picks the right rendering mode for the machine by itself; Compatibility forces the most conservative mode if a screen ever stutters, tears, or crackles; Quality forces full GPU rendering on hardware the automatic detection doesn't recognize. Changing it restarts the on-screen display, like the size presets."
|
||||
"**The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.",
|
||||
"**The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.",
|
||||
"**App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.1-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.1-alpha/archipelago",
|
||||
"current_version": "1.8.3-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.1-alpha",
|
||||
"sha256": "50fe97705717f8176afc03285a3a459b1d4e219dba0442aa4ee5b8cea8e3ec30",
|
||||
"size_bytes": 59896032
|
||||
"new_version": "1.8.3-alpha",
|
||||
"sha256": "23b608bfce575212edb873ded3e125505f42be0db6dc06ce5f9c1e51c232c22d",
|
||||
"size_bytes": 59900696
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.1-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.1-alpha/archipelago-frontend-1.8.1-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.1-alpha.tar.gz",
|
||||
"new_version": "1.8.1-alpha",
|
||||
"sha256": "ae77c97e8f1ec7d1044f7d565319ddc503f6f5b87dbc9a524fa9cc9639b04f29",
|
||||
"size_bytes": 97611693
|
||||
"current_version": "1.8.3-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago-frontend-1.8.3-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.3-alpha.tar.gz",
|
||||
"new_version": "1.8.3-alpha",
|
||||
"sha256": "13ca772e9a36c266b67e6eff2d51366616cdbedeab3a05ba5eba340332d3d6d4",
|
||||
"size_bytes": 97615528
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-13",
|
||||
"signature": "7a8932503d1c33156f79d11671bb275a2bb63347988da02562c3dd138287e7ed77c7d4cb0a1f6bcbcf8665da506fafedd2d81bb844de0cbcbaf2b9045210ca0b",
|
||||
"release_date": "2026-08-14",
|
||||
"signature": "35d5f56ef77cda1866051a3ff06f6e09ff0a23ac7b000a1ac4cf24e04cecabff00be7f3e240fc82ec605bff80c5690c55c296a300018562940d71ffc5df26c00",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.1-alpha"
|
||||
"version": "1.8.3-alpha"
|
||||
}
|
||||
|
||||
+17
-17
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Apps that refused to open inside the dashboard now embed like everything else.** Some apps ship browser headers that forbid being shown inside another page — correct hardening on the open web, but inside Archipelago it produced a dead grey pane when you opened them from My Apps (Alby Hub was the first to hit it). The app gate, which already checks your login on every request to an app, now removes just those framing headers on the way through; each app's own content-security rules pass through untouched. No more per-app proxy workarounds.",
|
||||
"**The network map no longer freezes kiosk TVs.** The animated federation map at 4K was too much for the deliberately conservative graphics settings the on-screen display used on every machine — settings chosen years back to stop audio crackle on much older hardware. Two fixes: on kiosk screens the map now opens in its flat 2D view (the 3D globe is one tap away, and remembered) and animates at half rate — invisible from the couch, half the work. And the display itself now recognizes what machine it runs on: older kiosk boxes keep the proven careful settings, modern ones finally get real GPU rendering.",
|
||||
"**New Settings → Display → Graphics choice for the on-screen display.** Auto (recommended) picks the right rendering mode for the machine by itself; Compatibility forces the most conservative mode if a screen ever stutters, tears, or crackles; Quality forces full GPU rendering on hardware the automatic detection doesn't recognize. Changing it restarts the on-screen display, like the size presets."
|
||||
"**The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.",
|
||||
"**The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.",
|
||||
"**App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.1-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.1-alpha/archipelago",
|
||||
"current_version": "1.8.3-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.1-alpha",
|
||||
"sha256": "50fe97705717f8176afc03285a3a459b1d4e219dba0442aa4ee5b8cea8e3ec30",
|
||||
"size_bytes": 59896032
|
||||
"new_version": "1.8.3-alpha",
|
||||
"sha256": "23b608bfce575212edb873ded3e125505f42be0db6dc06ce5f9c1e51c232c22d",
|
||||
"size_bytes": 59900696
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.1-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.1-alpha/archipelago-frontend-1.8.1-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.1-alpha.tar.gz",
|
||||
"new_version": "1.8.1-alpha",
|
||||
"sha256": "ae77c97e8f1ec7d1044f7d565319ddc503f6f5b87dbc9a524fa9cc9639b04f29",
|
||||
"size_bytes": 97611693
|
||||
"current_version": "1.8.3-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago-frontend-1.8.3-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.3-alpha.tar.gz",
|
||||
"new_version": "1.8.3-alpha",
|
||||
"sha256": "13ca772e9a36c266b67e6eff2d51366616cdbedeab3a05ba5eba340332d3d6d4",
|
||||
"size_bytes": 97615528
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-13",
|
||||
"signature": "7a8932503d1c33156f79d11671bb275a2bb63347988da02562c3dd138287e7ed77c7d4cb0a1f6bcbcf8665da506fafedd2d81bb844de0cbcbaf2b9045210ca0b",
|
||||
"release_date": "2026-08-14",
|
||||
"signature": "35d5f56ef77cda1866051a3ff06f6e09ff0a23ac7b000a1ac4cf24e04cecabff00be7f3e240fc82ec605bff80c5690c55c296a300018562940d71ffc5df26c00",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.1-alpha"
|
||||
"version": "1.8.3-alpha"
|
||||
}
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Normalize a third-party SVG mark onto the Archipelago app-icon canvas.
|
||||
|
||||
Store tiles render icons as-is — there is deliberately no runtime inset
|
||||
(it would double-margin every icon that already ships whitespace), so the
|
||||
margin must be baked into the file. House icons carry it naturally;
|
||||
third-party marks are usually full-bleed (Alby Hub's tile icon was the
|
||||
first to land edge-to-edge in the store, 2026-08-14). This wraps any SVG
|
||||
in a square canvas with a uniform inner margin, preserving the original
|
||||
untouched as a nested <svg>.
|
||||
|
||||
Usage:
|
||||
scripts/normalize-app-icon.py in.svg out.svg [--margin 0.12]
|
||||
|
||||
Margin is a fraction of the canvas per side (default 12%, the house look).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("src")
|
||||
ap.add_argument("dst")
|
||||
ap.add_argument("--margin", type=float, default=0.12,
|
||||
help="inner margin per side, fraction of canvas (default 0.12)")
|
||||
args = ap.parse_args()
|
||||
|
||||
svg = open(args.src, encoding="utf-8").read()
|
||||
|
||||
m = re.search(r"<svg\b[^>]*>", svg, re.S)
|
||||
if not m:
|
||||
print("error: no <svg> root element found", file=sys.stderr)
|
||||
return 1
|
||||
root = m.group(0)
|
||||
|
||||
vb = re.search(r'viewBox\s*=\s*["\']([\d.eE+\s,-]+)["\']', root)
|
||||
if vb:
|
||||
nums = [float(x) for x in re.split(r"[\s,]+", vb.group(1).strip())]
|
||||
if len(nums) != 4:
|
||||
print("error: unparseable viewBox", file=sys.stderr)
|
||||
return 1
|
||||
_, _, w, h = nums
|
||||
inner_viewbox = vb.group(1).strip()
|
||||
else:
|
||||
wm = re.search(r'width\s*=\s*["\']([\d.]+)', root)
|
||||
hm = re.search(r'height\s*=\s*["\']([\d.]+)', root)
|
||||
if not (wm and hm):
|
||||
print("error: no viewBox and no width/height to derive one", file=sys.stderr)
|
||||
return 1
|
||||
w, h = float(wm.group(1)), float(hm.group(1))
|
||||
inner_viewbox = f"0 0 {w} {h}"
|
||||
|
||||
# Square canvas fitting the larger dimension; the mark centers in it.
|
||||
side = max(w, h)
|
||||
canvas = 100.0
|
||||
margin = canvas * args.margin
|
||||
avail = canvas - 2 * margin
|
||||
scale_w = avail * (w / side)
|
||||
scale_h = avail * (h / side)
|
||||
x = (canvas - scale_w) / 2
|
||||
y = (canvas - scale_h) / 2
|
||||
|
||||
# Strip any XML prolog from the original; it nests inside the wrapper.
|
||||
inner = re.sub(r"^\s*<\?xml[^>]*\?>\s*", "", svg)
|
||||
|
||||
out = (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 {canvas:g} {canvas:g}">\n'
|
||||
f' <!-- normalized by scripts/normalize-app-icon.py: margin={args.margin:g} per side -->\n'
|
||||
f' <svg x="{x:.3f}" y="{y:.3f}" width="{scale_w:.3f}" height="{scale_h:.3f}" '
|
||||
f'viewBox="{inner_viewbox}" preserveAspectRatio="xMidYMid meet">\n'
|
||||
f'{inner}\n'
|
||||
f' </svg>\n'
|
||||
f'</svg>\n'
|
||||
)
|
||||
open(args.dst, "w", encoding="utf-8").write(out)
|
||||
print(f"wrote {args.dst}: {canvas:g}x{canvas:g} canvas, mark {scale_w:.1f}x{scale_h:.1f} at {args.margin:.0%} margin")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user