Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb98ebb682 | ||
|
|
0fac51b9c5 | ||
|
|
4f0d123f27 | ||
|
|
13b1329c21 | ||
|
|
c4aa72dccc | ||
|
|
d35474f774 | ||
|
|
a03f340bd1 | ||
|
|
caaa2e729e | ||
|
|
dc7b598558 | ||
|
|
69f3a355c7 |
@@ -26,7 +26,7 @@
|
||||
"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.",
|
||||
"tag": "NGIT // NOSTR // NO SILO",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
|
||||
"launchLabel": "Open GitWorkshop",
|
||||
"installLabel": "Install GitWorkshop",
|
||||
"detailsLabel": "How contribution works →"
|
||||
|
||||
@@ -55,6 +55,10 @@ impl RpcHandler {
|
||||
"did": id.did,
|
||||
"created_at": id.created_at,
|
||||
"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_npub": nostr_npub,
|
||||
"profile": id.profile,
|
||||
|
||||
@@ -377,6 +377,23 @@ async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
|
||||
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)]
|
||||
mod torrc_tests {
|
||||
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 {
|
||||
match name {
|
||||
"archipelago" => 80,
|
||||
"bitcoin" | "bitcoin-knots" => 8333,
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => 8333,
|
||||
"electrs" | "electrumx" => 50001,
|
||||
"lnd" => 8080,
|
||||
"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 {
|
||||
matches!(
|
||||
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.
|
||||
/// "archipelago" is the main web UI (nginx port 80).
|
||||
/// 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> {
|
||||
match app_id {
|
||||
"archipelago" => Some("archipelago"),
|
||||
"bitcoin-core" => Some("bitcoin-core"),
|
||||
"bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"),
|
||||
"electrumx" | "electrs" | "electrum" => Some("electrumx"),
|
||||
"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)]
|
||||
mod extract_lan_address_tests {
|
||||
use super::extract_lan_address;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"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.",
|
||||
"tag": "NGIT // NOSTR // NO SILO",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
|
||||
"launchLabel": "Open GitWorkshop",
|
||||
"installLabel": "Install GitWorkshop",
|
||||
"detailsLabel": "How contribution works →"
|
||||
|
||||
@@ -45,13 +45,13 @@
|
||||
</button>
|
||||
</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/30 text-xs mt-1">Create one in Settings → Credentials</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="identity in identities"
|
||||
v-for="identity in userIdentities"
|
||||
:key="identity.id"
|
||||
type="button"
|
||||
role="radio"
|
||||
@@ -130,6 +130,7 @@ interface Identity {
|
||||
is_default: boolean
|
||||
nostr_pubkey?: string
|
||||
nostr_npub?: string
|
||||
is_node?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -148,10 +149,23 @@ const selectedId = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
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'))
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
@@ -169,8 +183,8 @@ async function loadIdentities() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
|
||||
identities.value = res.identities || []
|
||||
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey)
|
||||
|| identities.value.find(i => i.nostr_pubkey)
|
||||
const defaultId = userIdentities.value.find(i => i.is_default && i.nostr_pubkey)
|
||||
|| userIdentities.value.find(i => i.nostr_pubkey)
|
||||
if (defaultId) selectedId.value = defaultId.id
|
||||
} catch (error) {
|
||||
identities.value = []
|
||||
@@ -183,7 +197,7 @@ async function loadIdentities() {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { appPortIsGateFronted } from '@/views/appSession/appSessionConfig'
|
||||
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 type { AppCredential, AppCredentialsResponse } from '@/types/api'
|
||||
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
|
||||
* Portainer first-run token entirely. */
|
||||
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)) {
|
||||
void prepareCredentialLaunch(appId, opts.path)
|
||||
return
|
||||
|
||||
@@ -365,6 +365,7 @@ import AppGrid from './discover/AppGrid.vue'
|
||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured, type CatalogStorefront } from './discover/curatedApps'
|
||||
import { isServiceContainer } from './apps/serviceNames'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
@@ -733,6 +734,16 @@ onBeforeUnmount(() => {
|
||||
|
||||
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 {
|
||||
if (!bitcoinPruned.value) 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) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
installToast(app)
|
||||
try {
|
||||
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 })
|
||||
@@ -780,8 +790,7 @@ async function installApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
installToast(app)
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
|
||||
if ((app as Record<string, unknown>).containerConfig) {
|
||||
|
||||
@@ -185,6 +185,7 @@ import {
|
||||
getCuratedAppList,
|
||||
} from './marketplace/marketplaceData'
|
||||
import { fetchAppCatalog } from './discover/curatedApps'
|
||||
import { isServiceContainer } from './apps/serviceNames'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -207,6 +208,17 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
const installingApps = server.installingApps
|
||||
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)
|
||||
// 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
|
||||
// global watcher, and a forced jump to My Apps yanked the user out of the
|
||||
// page they were deliberately browsing.
|
||||
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
|
||||
installToast(app)
|
||||
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
@@ -543,7 +555,7 @@ async function installCommunityApp(app: MarketplaceApp) {
|
||||
|
||||
queueInstall(app)
|
||||
// Stay on the store page (see installApp).
|
||||
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
|
||||
installToast(app)
|
||||
|
||||
try {
|
||||
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 { 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 { __setSignedCatalogForTests } from '../../discover/curatedApps'
|
||||
|
||||
@@ -159,9 +159,9 @@ describe('appSessionConfig', () => {
|
||||
// Source is intentionally absent from SIGNED until owner UAT passes. It
|
||||
// must follow the already-working dashboard ingress instead of assuming
|
||||
// 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'))
|
||||
.toBe('/app/archipelago-source/')
|
||||
.toBe(`/app/archipelago-source${DEFAULT_GITWORKSHOP_REPO_PATH}`)
|
||||
expect(resolveAppUrl('archipelago-source', '/search'))
|
||||
.toBe('/app/archipelago-source/search')
|
||||
})
|
||||
|
||||
@@ -61,6 +61,10 @@ export const PROXY_APPS: Record<string, string> = {
|
||||
'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. */
|
||||
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.
|
||||
if (id === 'archipelago-source') {
|
||||
const base = PROXY_APPS['archipelago-source']!
|
||||
if (!routeQueryPath) return base
|
||||
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : `/${routeQueryPath}`)
|
||||
const path = routeQueryPath || DEFAULT_GITWORKSHOP_REPO_PATH
|
||||
return base.replace(/\/+$/, '') + (path.startsWith('/') ? path : `/${path}`)
|
||||
}
|
||||
|
||||
// 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 { ref } from 'vue'
|
||||
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 {
|
||||
return {
|
||||
@@ -76,6 +76,16 @@ describe('appsConfig service filtering', () => {
|
||||
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', () => {
|
||||
const pkg = makePkg('gitea', 'Gitea', 'dev')
|
||||
pkg['static-files']!.icon = 'git-branch'
|
||||
@@ -141,6 +151,19 @@ describe('appsConfig service filtering', () => {
|
||||
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', () => {
|
||||
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
|
||||
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
|
||||
|
||||
@@ -37,7 +37,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
|
||||
// Known app -> category mappings (matches App Store categorisation)
|
||||
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',
|
||||
'fedimint': 'money', 'fedimint-gateway': 'money',
|
||||
'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
|
||||
// indexes for 10m+ on first run). A genuinely 'unhealthy' backend still
|
||||
// blocks. Apps that rely on a runtime interface-address keep the strict gate.
|
||||
const blockedByHealth =
|
||||
pkg.health === 'unhealthy' || (pkg.health === 'starting' && !hasKnownLaunchUrl)
|
||||
const blockedByHealth = !isAppReadyForLaunch(pkg) ||
|
||||
(pkg.health === 'starting' && !hasKnownLaunchUrl)
|
||||
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 {
|
||||
const appId = pkg?.manifest?.id || id
|
||||
if (
|
||||
@@ -272,6 +287,9 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null):
|
||||
) {
|
||||
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 ''
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
|
||||
export const SERVICE_NAMES = new Set([
|
||||
'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,
|
||||
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
|
||||
// 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
|
||||
// without the main app should not mark the app as installed in the UI.
|
||||
export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
'cuprate-ui': ['cuprate-ui', 'cuprate'],
|
||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
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) */
|
||||
export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
'cuprate-ui': ['cuprate-ui', 'cuprate'],
|
||||
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
|
||||
|
||||
Reference in New Issue
Block a user