Compare commits

...
Author SHA1 Message Date
archipelago eb98ebb682 Merge PR #158: preserve Bitcoin Core Tor service naming 2026-09-13 01:37:15 -04:00
archipelago 0fac51b9c5 chore: preserve signed release catalog 2026-09-12 16:00:16 -04:00
archipelago 4f0d123f27 feat: open GitWorkshop at Archipelago repository
Demo images / Build & push demo images (push) Successful in 3m33s
2026-09-12 15:57:57 -04:00
archipelago 13b1329c21 test: keep Cuprate stack as one app entry
Demo images / Build & push demo images (push) Successful in 4m0s
2026-09-12 15:33:05 -04:00
archipelago c4aa72dccc fix: route installs to apps or services
Demo images / Build & push demo images (push) Successful in 3m39s
2026-09-12 15:07:33 -04:00
archipelago d35474f774 fix: defensively hide legacy node identity
Demo images / Build & push demo images (push) Successful in 3m31s
2026-09-12 10:24:01 -04:00
archipelago a03f340bd1 fix: keep node key out of profile signer picker
Demo images / Build & push demo images (push) Successful in 3m26s
2026-09-12 10:06:41 -04:00
archipelago caaa2e729e fix: gate app launches on health readiness
Demo images / Build & push demo images (push) Successful in 3m47s
2026-09-12 09:35:25 -04:00
ssmithxandClaude Sonnet 5 dc7b598558 fix(tor): un-alias bitcoin-core's hidden-service name; add regression tests
read_tor_address("bitcoin-core") was resolving through tor_service_name to
the shared "bitcoin" alias, but enrollment (install.rs auto-enroll and the
tor.create-service RPC) always names HiddenServiceDir/tor-hostnames entries
using the raw package_id verbatim — never canonicalized. On a real node
that's hidden_service_bitcoin-core, which the aliased lookup never found,
so the per-app UI Tor badge stayed empty even after the previous commit
made bitcoin-core auto-enrollable.

Give bitcoin-core its own identity-mapped arm instead of folding it into
the legacy bitcoin/bitcoin-knots/bitcoind alias, and pin all three lookup
tables (known_service_port, is_protocol_service, tor_service_name) with
regression tests so this alias-drift class of bug can't recur silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 16:26:59 +00:00
ssmithxandClaude Sonnet 5 69f3a355c7 fix(tor): recognize bitcoin-core in Tor auto-enrollment tables
apps/bitcoin-core/manifest.yml uses id "bitcoin-core", but
known_service_port/is_protocol_service (tor/mod.rs) and
tor_service_name (docker_packages.rs) only matched "bitcoin" and
"bitcoin-knots", so the app silently never got auto-enrolled for a
P2P (8333) hidden service at install time, and the UI's Tor address
lookup for it always returned None.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 15:28:17 +00:00
16 changed files with 172 additions and 26 deletions
+1 -1
View File
@@ -26,7 +26,7 @@
"headline": "Your node. Your source.", "headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.", "description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO", "tag": "NGIT // NOSTR // NO SILO",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy", "path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"launchLabel": "Open GitWorkshop", "launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop", "installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →" "detailsLabel": "How contribution works →"
@@ -55,6 +55,10 @@ impl RpcHandler {
"did": id.did, "did": id.did,
"created_at": id.created_at, "created_at": id.created_at,
"is_default": is_default, "is_default": is_default,
// The node's operational Nostr key is intentionally
// distinguishable from user profile identities. Clients
// must never offer it in app sign-in pickers.
"is_node": is_node,
"nostr_pubkey": nostr_pubkey, "nostr_pubkey": nostr_pubkey,
"nostr_npub": nostr_npub, "nostr_npub": nostr_npub,
"profile": id.profile, "profile": id.profile,
+19 -2
View File
@@ -377,6 +377,23 @@ async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
Ok(()) Ok(())
} }
#[cfg(test)]
mod known_service_tests {
use super::{is_protocol_service, known_service_port};
#[test]
fn bitcoin_core_is_a_protocol_service_on_the_p2p_port() {
// Regression: apps/bitcoin-core/manifest.yml uses id "bitcoin-core",
// distinct from the legacy "bitcoin"/"bitcoin-knots" ids. Missing
// here means auto-enrollment silently skips it (known_service_port
// returns 0) and, separately, regenerate_torrc falls back to the
// web-app HiddenServicePort-80 default instead of forwarding 8333
// straight through.
assert_eq!(known_service_port("bitcoin-core"), 8333);
assert!(is_protocol_service("bitcoin-core"));
}
}
#[cfg(test)] #[cfg(test)]
mod torrc_tests { mod torrc_tests {
use super::app_hidden_service_port_line; use super::app_hidden_service_port_line;
@@ -594,7 +611,7 @@ fn is_valid_v3_onion(s: &str) -> bool {
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 { pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
match name { match name {
"archipelago" => 80, "archipelago" => 80,
"bitcoin" | "bitcoin-knots" => 8333, "bitcoin" | "bitcoin-core" | "bitcoin-knots" => 8333,
"electrs" | "electrumx" => 50001, "electrs" | "electrumx" => 50001,
"lnd" => 8080, "lnd" => 8080,
"btcpay" | "btcpay-server" | "btcpayserver" => 23000, "btcpay" | "btcpay-server" | "btcpayserver" => 23000,
@@ -619,7 +636,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool { pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
matches!( matches!(
name, name,
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd" "bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
) )
} }
@@ -657,9 +657,19 @@ fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
/// Map app_id to Tor hidden service directory name. /// Map app_id to Tor hidden service directory name.
/// "archipelago" is the main web UI (nginx port 80). /// "archipelago" is the main web UI (nginx port 80).
/// Supports container names from deploy (archy-*, btcpay-server, etc.). /// Supports container names from deploy (archy-*, btcpay-server, etc.).
///
/// This must match what enrollment actually names the hidden service dir
/// with — both the install-time auto-enroll (`install.rs`) and the manual
/// `tor.create-service` RPC write `HiddenServiceDir` using the raw
/// `package_id`/`name` verbatim, with no canonicalization. So `bitcoin-core`
/// gets its own identity arm rather than folding into the "bitcoin" alias:
/// aliasing it here without also canonicalizing the write side would point
/// this lookup at `hidden_service_bitcoin`, which never gets created — the
/// on-disk dir is always `hidden_service_bitcoin-core` for this app id.
fn tor_service_name(app_id: &str) -> Option<&'static str> { fn tor_service_name(app_id: &str) -> Option<&'static str> {
match app_id { match app_id {
"archipelago" => Some("archipelago"), "archipelago" => Some("archipelago"),
"bitcoin-core" => Some("bitcoin-core"),
"bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"), "bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"),
"electrumx" | "electrs" | "electrum" => Some("electrumx"), "electrumx" | "electrs" | "electrum" => Some("electrumx"),
"lnd" | "lnd-ui" => Some("lnd"), "lnd" | "lnd-ui" => Some("lnd"),
@@ -906,6 +916,28 @@ mod launch_url_port_tests {
} }
} }
#[cfg(test)]
mod tor_service_name_tests {
use super::tor_service_name;
#[test]
fn bitcoin_core_resolves_to_its_own_hidden_service_dir() {
// Regression: enrollment (install.rs, tor.create-service) writes
// HiddenServiceDir/tor-hostnames entries using the raw package_id
// verbatim, never canonicalized. Aliasing "bitcoin-core" to the
// shared "bitcoin" name here would point reads at a directory
// enrollment never creates.
assert_eq!(tor_service_name("bitcoin-core"), Some("bitcoin-core"));
}
#[test]
fn legacy_bitcoin_ids_share_the_bitcoin_alias() {
assert_eq!(tor_service_name("bitcoin"), Some("bitcoin"));
assert_eq!(tor_service_name("bitcoin-knots"), Some("bitcoin"));
assert_eq!(tor_service_name("bitcoind"), Some("bitcoin"));
}
}
#[cfg(test)] #[cfg(test)]
mod extract_lan_address_tests { mod extract_lan_address_tests {
use super::extract_lan_address; use super::extract_lan_address;
+1 -1
View File
@@ -26,7 +26,7 @@
"headline": "Your node. Your source.", "headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.", "description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO", "tag": "NGIT // NOSTR // NO SILO",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy", "path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"launchLabel": "Open GitWorkshop", "launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop", "installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →" "detailsLabel": "How contribution works →"
@@ -45,13 +45,13 @@
</button> </button>
</div> </div>
<div v-else-if="identities.length === 0" class="text-center py-8"> <div v-else-if="userIdentities.length === 0" class="text-center py-8">
<p class="text-white/50 text-sm">No identities found.</p> <p class="text-white/50 text-sm">No identities found.</p>
<p class="text-white/30 text-xs mt-1">Create one in Settings &rarr; Credentials</p> <p class="text-white/30 text-xs mt-1">Create one in Settings &rarr; Credentials</p>
</div> </div>
<button <button
v-for="identity in identities" v-for="identity in userIdentities"
:key="identity.id" :key="identity.id"
type="button" type="button"
role="radio" role="radio"
@@ -130,6 +130,7 @@ interface Identity {
is_default: boolean is_default: boolean
nostr_pubkey?: string nostr_pubkey?: string
nostr_npub?: string nostr_npub?: string
is_node?: boolean
} }
const props = defineProps<{ const props = defineProps<{
@@ -148,10 +149,23 @@ const selectedId = ref<string | null>(null)
const loading = ref(false) const loading = ref(false)
const loadError = ref<string | null>(null) const loadError = ref<string | null>(null)
// The node key authenticates the appliance itself (mesh/discovery and other
// platform operations), not the person's public profile. The API marks it
// explicitly; keep a defensive name/purpose fallback for older nodes that do
// not send is_node yet.
const userIdentities = computed(() => identities.value.filter(identity =>
// `node-<pubkey>` is the deterministic id used by older node APIs before
// the explicit is_node marker was added. Keep this fallback so an older
// backend can never expose the appliance key as a profile choice.
!identity.is_node
&& !identity.id.trim().toLowerCase().startsWith('node-')
&& identity.name.trim().toLowerCase() !== 'node'
))
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel')) useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
const hasNostrKey = computed(() => { const hasNostrKey = computed(() => {
const selected = identities.value.find(i => i.id === selectedId.value) const selected = userIdentities.value.find(i => i.id === selectedId.value)
return selected?.nostr_pubkey != null return selected?.nostr_pubkey != null
}) })
@@ -169,8 +183,8 @@ async function loadIdentities() {
try { try {
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' }) const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
identities.value = res.identities || [] identities.value = res.identities || []
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey) const defaultId = userIdentities.value.find(i => i.is_default && i.nostr_pubkey)
|| identities.value.find(i => i.nostr_pubkey) || userIdentities.value.find(i => i.nostr_pubkey)
if (defaultId) selectedId.value = defaultId.id if (defaultId) selectedId.value = defaultId.id
} catch (error) { } catch (error) {
identities.value = [] identities.value = []
@@ -183,7 +197,7 @@ async function loadIdentities() {
} }
function confirm() { function confirm() {
const selected = identities.value.find(i => i.id === selectedId.value) const selected = userIdentities.value.find(i => i.id === selectedId.value)
if (selected) emit('select', selected) if (selected) emit('select', selected)
} }
+10 -1
View File
@@ -7,7 +7,8 @@ import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils
import { directAppUrl, HOST_FRAME_APPS, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig' import { directAppUrl, HOST_FRAME_APPS, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig' import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { resolveAppIcon } from '@/views/apps/appsConfig' import { resolveAppIcon, isAppReadyForLaunch } from '@/views/apps/appsConfig'
import { useToast } from '@/composables/useToast'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro' import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
import type { AppCredential, AppCredentialsResponse } from '@/types/api' import type { AppCredential, AppCredentialsResponse } from '@/types/api'
import { resolveAppCredentials } from '@/views/apps/appCredentials' import { resolveAppCredentials } from '@/views/apps/appCredentials'
@@ -290,6 +291,14 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
* Previously each Apps view owned a private modal, so Home skipped the * Previously each Apps view owned a private modal, so Home skipped the
* Portainer first-run token entirely. */ * Portainer first-run token entirely. */
function openSession(appId: string, opts: LaunchOptions = {}) { function openSession(appId: string, opts: LaunchOptions = {}) {
// Home/goal/deep-link launchers do not pass through AppCard.canLaunch.
// Apply the same readiness gate here so a container that has just entered
// `running` cannot race nginx and show a transient 502 to the user.
const pkg = useAppStore().data?.['package-data']?.[appId]
if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) {
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
return
}
if (!opts.skipCredentialPrompt && CREDENTIAL_INTERSTITIAL_APPS.has(appId)) { if (!opts.skipCredentialPrompt && CREDENTIAL_INTERSTITIAL_APPS.has(appId)) {
void prepareCredentialLaunch(appId, opts.path) void prepareCredentialLaunch(appId, opts.path)
return return
+13 -4
View File
@@ -365,6 +365,7 @@ import AppGrid from './discover/AppGrid.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue' import InstallVersionModal from '@/components/InstallVersionModal.vue'
import type { MarketplaceApp, FeaturedApp } from './discover/types' import type { MarketplaceApp, FeaturedApp } from './discover/types'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured, type CatalogStorefront } from './discover/curatedApps' import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured, type CatalogStorefront } from './discover/curatedApps'
import { isServiceContainer } from './apps/serviceNames'
const router = useRouter() const router = useRouter()
const store = useAppStore() const store = useAppStore()
@@ -733,6 +734,16 @@ onBeforeUnmount(() => {
const toast = useToast() const toast = useToast()
function installToast(app: MarketplaceApp) {
const service = isServiceContainer(app.id)
const destination = service ? 'Services' : 'My Apps'
toast.action(
`Installing ${app.title ?? app.id} — it will appear in ${destination}`,
{ label: `View ${destination}`, onClick: () => router.push({ path: '/dashboard/apps', query: service ? { tab: 'services' } : {} }) },
{ variant: 'info', duration: 15000 },
)
}
function installBlockedReason(appId: string): string | undefined { function installBlockedReason(appId: string): string | undefined {
if (!bitcoinPruned.value) return undefined if (!bitcoinPruned.value) return undefined
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
@@ -766,8 +777,7 @@ function failInstall(app: MarketplaceApp, err: unknown) {
async function installApp(app: MarketplaceApp, versionOverride?: string) { async function installApp(app: MarketplaceApp, versionOverride?: string) {
if (installingApps.has(app.id) || isInstalled(app.id)) return if (installingApps.has(app.id) || isInstalled(app.id)) return
queueInstall(app) queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps") installToast(app)
router.push('/dashboard/apps').catch(() => {})
try { try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl const installUrl = app.url || app.manifestUrl || app.s9pkUrl
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 }) await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
@@ -780,8 +790,7 @@ async function installApp(app: MarketplaceApp, versionOverride?: string) {
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) { async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
queueInstall(app) queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps") installToast(app)
router.push('/dashboard/apps').catch(() => {})
try { try {
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version } const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
if ((app as Record<string, unknown>).containerConfig) { if ((app as Record<string, unknown>).containerConfig) {
+14 -2
View File
@@ -185,6 +185,7 @@ import {
getCuratedAppList, getCuratedAppList,
} from './marketplace/marketplaceData' } from './marketplace/marketplaceData'
import { fetchAppCatalog } from './discover/curatedApps' import { fetchAppCatalog } from './discover/curatedApps'
import { isServiceContainer } from './apps/serviceNames'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -207,6 +208,17 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
const installingApps = server.installingApps const installingApps = server.installingApps
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX' const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
function installToast(app: MarketplaceApp) {
const service = isServiceContainer(app.id)
const tab = service ? 'services' : 'apps'
const destination = service ? 'Services' : 'My Apps'
toast.action(
`Installing ${app.title ?? app.id} — it will appear in ${destination}`,
{ label: `View ${destination}`, onClick: () => router.push({ path: '/dashboard/apps', query: service ? { tab } : {} }) },
{ variant: 'info', duration: 15000 },
)
}
// Install progress tracking is now in serverStore (global watcher on WebSocket data) // Install progress tracking is now in serverStore (global watcher on WebSocket data)
// so it works regardless of which page is active // so it works regardless of which page is active
@@ -518,7 +530,7 @@ async function installApp(app: MarketplaceApp) {
// Stay on the store page: the tile itself shows install progress via the // Stay on the store page: the tile itself shows install progress via the
// global watcher, and a forced jump to My Apps yanked the user out of the // global watcher, and a forced jump to My Apps yanked the user out of the
// page they were deliberately browsing. // page they were deliberately browsing.
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps") installToast(app)
try { try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl const installUrl = app.url || app.manifestUrl || app.s9pkUrl
@@ -543,7 +555,7 @@ async function installCommunityApp(app: MarketplaceApp) {
queueInstall(app) queueInstall(app)
// Stay on the store page (see installApp). // Stay on the store page (see installApp).
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps") installToast(app)
try { try {
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version } const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
@@ -1,5 +1,5 @@
import { describe, expect, it, beforeEach } from 'vitest' import { describe, expect, it, beforeEach } from 'vitest'
import { HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig' import { DEFAULT_GITWORKSHOP_REPO_PATH, HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_HOST_FRAME_APPS, GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig' import { GENERATED_HOST_FRAME_APPS, GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { __setSignedCatalogForTests } from '../../discover/curatedApps' import { __setSignedCatalogForTests } from '../../discover/curatedApps'
@@ -159,9 +159,9 @@ describe('appSessionConfig', () => {
// Source is intentionally absent from SIGNED until owner UAT passes. It // Source is intentionally absent from SIGNED until owner UAT passes. It
// must follow the already-working dashboard ingress instead of assuming // must follow the already-working dashboard ingress instead of assuming
// that the same address also exposes a dedicated high port. // that the same address also exposes a dedicated high port.
expect(resolveAppUrl('archipelago-source')).toBe('/app/archipelago-source/') expect(resolveAppUrl('archipelago-source')).toBe(`/app/archipelago-source${DEFAULT_GITWORKSHOP_REPO_PATH}`)
expect(resolveAppUrl('archipelago-source', undefined, 'http://localhost:8337')) expect(resolveAppUrl('archipelago-source', undefined, 'http://localhost:8337'))
.toBe('/app/archipelago-source/') .toBe(`/app/archipelago-source${DEFAULT_GITWORKSHOP_REPO_PATH}`)
expect(resolveAppUrl('archipelago-source', '/search')) expect(resolveAppUrl('archipelago-source', '/search'))
.toBe('/app/archipelago-source/search') .toBe('/app/archipelago-source/search')
}) })
@@ -61,6 +61,10 @@ export const PROXY_APPS: Record<string, string> = {
'uptime-kuma': '/app/uptime-kuma/', 'uptime-kuma': '/app/uptime-kuma/',
} }
/** The repository shown when GitWorkshop is opened from the app launcher. */
export const DEFAULT_GITWORKSHOP_REPO_PATH =
'/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy'
/** App launches use direct ports. Do not route through /app/... path proxies. */ /** App launches use direct ports. Do not route through /app/... path proxies. */
export const HTTPS_PROXY_PATHS: Record<string, string> = { export const HTTPS_PROXY_PATHS: Record<string, string> = {
} }
@@ -137,8 +141,8 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
// high port is reachable through the same address. // high port is reachable through the same address.
if (id === 'archipelago-source') { if (id === 'archipelago-source') {
const base = PROXY_APPS['archipelago-source']! const base = PROXY_APPS['archipelago-source']!
if (!routeQueryPath) return base const path = routeQueryPath || DEFAULT_GITWORKSHOP_REPO_PATH
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : `/${routeQueryPath}`) return base.replace(/\/+$/, '') + (path.startsWith('/') ? path : `/${path}`)
} }
// Bitcoin UI is a host-network companion on :8334. Do not launch it via // Bitcoin UI is a host-network companion on :8334. Do not launch it via
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { ref } from 'vue' import { ref } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api' import { PackageState, type PackageDataEntry } from '@/types/api'
import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig' import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, isAppReadyForLaunch, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
function makePkg(id: string, title: string, category: string): PackageDataEntry { function makePkg(id: string, title: string, category: string): PackageDataEntry {
return { return {
@@ -76,6 +76,16 @@ describe('appsConfig service filtering', () => {
expect(services.map(([id]) => id)).toEqual(['core-lnd-ui']) expect(services.map(([id]) => id)).toEqual(['core-lnd-ui'])
}) })
it('shows Cuprate as one My Apps entry while hiding its daemon dependency', () => {
const entries: Array<[string, PackageDataEntry]> = [
['cuprate-ui', makePkg('cuprate-ui', 'Cuprate UI', 'money')],
['cuprate', makePkg('cuprate', 'Cuprate daemon', 'money')],
]
;(entries[0][1].manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'http://localhost:18091' } }
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate-ui'])
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual(['cuprate'])
})
it('falls back to packaged app icon when static icon token is not a path', () => { it('falls back to packaged app icon when static icon token is not a path', () => {
const pkg = makePkg('gitea', 'Gitea', 'dev') const pkg = makePkg('gitea', 'Gitea', 'dev')
pkg['static-files']!.icon = 'git-branch' pkg['static-files']!.icon = 'git-branch'
@@ -141,6 +151,19 @@ describe('appsConfig service filtering', () => {
expect(canLaunch(confirmedUi)).toBe(true) expect(canLaunch(confirmedUi)).toBe(true)
}) })
it('does not launch a health-checked app during the running-before-ready race', () => {
const pkg = makePkg('archipelago-source', 'GitWorkshop', 'development')
;(pkg.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
;(pkg.manifest as unknown as Record<string, unknown>).health_check = { path: '/healthz' }
pkg.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8337' } }, status: 'running' } as unknown as PackageDataEntry['installed']
pkg.health = null
expect(isAppReadyForLaunch(pkg)).toBe(false)
expect(canLaunch(pkg)).toBe(false)
expect(launchBlockedReason(pkg.manifest.id, pkg)).toContain('Starting up')
pkg.health = 'healthy'
expect(canLaunch(pkg)).toBe(true)
})
it('never offers Launch for curated service containers even with a UI flag', () => { it('never offers Launch for curated service containers even with a UI flag', () => {
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media') const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } } ;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
+21 -3
View File
@@ -37,7 +37,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
// Known app -> category mappings (matches App Store categorisation) // Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = { export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money', 'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money', 'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media', 'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
@@ -259,11 +259,26 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
// the tile stays launchable while the backend is still 'starting' (ElectrumX // the tile stays launchable while the backend is still 'starting' (ElectrumX
// indexes for 10m+ on first run). A genuinely 'unhealthy' backend still // indexes for 10m+ on first run). A genuinely 'unhealthy' backend still
// blocks. Apps that rely on a runtime interface-address keep the strict gate. // blocks. Apps that rely on a runtime interface-address keep the strict gate.
const blockedByHealth = const blockedByHealth = !isAppReadyForLaunch(pkg) ||
pkg.health === 'unhealthy' || (pkg.health === 'starting' && !hasKnownLaunchUrl) (pkg.health === 'starting' && !hasKnownLaunchUrl)
return !!hasUI && pkg.state === 'running' && !blockedByHealth return !!hasUI && pkg.state === 'running' && !blockedByHealth
} }
/**
* A published port is not the same thing as a usable app. During the short
* interval between the container entering `running` and its HTTP health check
* passing, nginx quite correctly returns 502 because the upstream has not
* bound its socket yet. Keep every app with a declared health check out of
* the launch path until the platform has observed readiness. Apps without a
* health check retain the legacy state/port behaviour.
*/
export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
const manifest = pkg.manifest as unknown as Record<string, unknown>
const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
if (!hasHealthCheck) return pkg.health !== 'unhealthy'
return pkg.health === 'healthy'
}
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string { export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
const appId = pkg?.manifest?.id || id const appId = pkg?.manifest?.id || id
if ( if (
@@ -272,6 +287,9 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null):
) { ) {
return 'Guardian opens a wait page until Bitcoin finishes initial sync.' return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
} }
if (pkg && pkg.state === PackageState.Running && !isAppReadyForLaunch(pkg)) {
return 'Starting up — Launch will appear when the app is ready.'
}
return '' return ''
} }
+2
View File
@@ -14,6 +14,8 @@
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim. // SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
export const SERVICE_NAMES = new Set([ export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor', 'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
// Cuprate's daemon is the backend dependency of the Cuprate UI app.
'cuprate',
// Headless backends with no user-facing UI: the Fedimint ecash client daemon, // Headless backends with no user-facing UI: the Fedimint ecash client daemon,
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the // the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
// built-in Mesh tab) belong in Services, not My Apps. // built-in Mesh tab) belong in Services, not My Apps.
@@ -308,6 +308,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
// Supporting containers (DBs, caches, workers) do NOT — having only a DB // Supporting containers (DBs, caches, workers) do NOT — having only a DB
// without the main app should not mark the app as installed in the UI. // without the main app should not mark the app as installed in the UI.
export const INSTALLED_ALIASES: Record<string, string[]> = { export const INSTALLED_ALIASES: Record<string, string[]> = {
'cuprate-ui': ['cuprate-ui', 'cuprate'],
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'], mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
bitcoin: ['bitcoin-knots'], bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server'], btcpay: ['btcpay-server'],
@@ -68,6 +68,7 @@ const REGISTRY = 'source.archipelago-foundation.org/lfg2025'
/** Marketplace app ID -> backend package keys (for "Already Installed" when first-boot/deploy created them) */ /** Marketplace app ID -> backend package keys (for "Already Installed" when first-boot/deploy created them) */
export const INSTALLED_ALIASES: Record<string, string[]> = { export const INSTALLED_ALIASES: Record<string, string[]> = {
'cuprate-ui': ['cuprate-ui', 'cuprate'],
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'], mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
bitcoin: ['bitcoin-knots'], bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'], btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],