Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit dc80b552a1
1639 changed files with 352647 additions and 0 deletions
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getAppUsage, recordAppLaunch } from '../appUsage'
describe('appUsage', () => {
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
it('starts empty when no usage has been recorded', () => {
expect(getAppUsage()).toEqual({})
})
it('records launch count and latest launch time', () => {
recordAppLaunch('filebrowser', 1000)
recordAppLaunch('filebrowser', 2000)
expect(getAppUsage()).toEqual({
filebrowser: {
count: 2,
lastLaunchedAt: 2000,
},
})
})
it('ignores corrupt stored usage', () => {
localStorage.setItem('archipelago-app-usage', 'not-json')
expect(getAppUsage()).toEqual({})
})
})
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { explainReceiveAddressFailure } from '../bitcoinReceive'
describe('explainReceiveAddressFailure', () => {
it('explains locked wallet failures', () => {
expect(explainReceiveAddressFailure(new Error('wallet locked'))).toContain('wallet is locked')
})
it('explains sync failures', () => {
expect(explainReceiveAddressFailure(new Error('chain backend is still syncing'))).toContain('still syncing')
})
it('explains empty address responses', () => {
expect(explainReceiveAddressFailure(new Error('LND did not return a Bitcoin address'))).toContain('did not return an address')
})
it('explains lnd transport failures', () => {
expect(explainReceiveAddressFailure(new Error('LND REST connection failed'))).toContain('not responding cleanly')
})
it('keeps bitcoin address generation failures visible', () => {
expect(explainReceiveAddressFailure(new Error('Bitcoin address generation failed: LND is not ready'))).toContain('Bitcoin address generation failed')
})
describe('structured reason codes (backend [CODE] token)', () => {
it('maps an unreachable REST endpoint to a starting/recovering message — NOT locked (.228 regression)', () => {
const msg = explainReceiveAddressFailure(
new Error('Bitcoin address unavailable [LND_REST_UNREACHABLE]: service not reachable'),
)
expect(msg).toContain('starting up or recovering')
expect(msg.toLowerCase()).not.toContain('locked')
})
it('maps a genuinely locked wallet to the locked message', () => {
expect(
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_WALLET_LOCKED]: locked')),
).toContain('locked')
})
it('maps an uninitialized wallet to the setup message', () => {
expect(
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_WALLET_UNINITIALIZED]: x')),
).toContain('has not been set up')
})
it('maps a syncing wallet to the syncing message', () => {
expect(
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_SYNCING]: x')),
).toContain('syncing')
})
it('prefers the code over substrings even when the detail text is misleading', () => {
// Detail mentions neither "locked" nor "unlock"; code must still win.
const msg = explainReceiveAddressFailure(
new Error('Bitcoin address unavailable [LND_REST_UNREACHABLE]: wallet service down'),
)
expect(msg).toContain('starting up or recovering')
})
})
})
@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchGitHubAppInfo, fetchMultipleAppInfo } from '../githubAppInfo'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
describe('fetchGitHubAppInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns empty object for invalid repo URL', async () => {
const result = await fetchGitHubAppInfo('not-a-url', 'test')
expect(result).toEqual({})
expect(mockFetch).not.toHaveBeenCalled()
})
it('returns empty object when repo API returns non-OK', async () => {
// Start9 repo check
mockFetch.mockResolvedValueOnce({ ok: false })
// Original repo fetch fails
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 })
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
expect(result).toEqual({})
})
it('fetches repo info successfully', async () => {
// Start9 wrapper check — not found
mockFetch.mockResolvedValueOnce({ ok: false })
// Repo API call
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
description: 'A Bitcoin node',
homepage: 'https://bitcoin.org',
html_url: 'https://github.com/owner/repo',
}),
})
// README fetch
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ content: btoa('# README') }),
})
// Icon path checks — all fail
for (let i = 0; i < 6; i++) {
mockFetch.mockResolvedValueOnce({ ok: false })
}
// Releases check — no icon
mockFetch.mockResolvedValueOnce({ ok: false })
// Raw icon URL HEAD checks — all fail
for (let i = 0; i < 6; i++) {
mockFetch.mockResolvedValueOnce({ ok: false })
}
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
expect(result.description).toBe('A Bitcoin node')
expect(result.readme).toBe('# README')
expect(result.homepage).toBe('https://bitcoin.org')
})
it('finds icon from repository contents', async () => {
// Start9 wrapper — not found
mockFetch.mockResolvedValueOnce({ ok: false })
// Repo API
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ description: '', html_url: 'https://github.com/o/r' }),
})
// README
mockFetch.mockResolvedValueOnce({ ok: false })
// First icon path (icon.png) — found!
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ download_url: 'https://raw.github.com/o/r/main/icon.png' }),
})
const result = await fetchGitHubAppInfo('https://github.com/o/r', 'test')
expect(result.icon).toBe('https://raw.github.com/o/r/main/icon.png')
})
it('tries Start9Labs wrapper repo first', async () => {
// Start9 wrapper check — found!
mockFetch.mockResolvedValueOnce({ ok: true })
// Now fetches Start9Labs/app-startos repo
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
description: 'Start9 wrapper',
html_url: 'https://github.com/Start9Labs/bitcoin-startos',
}),
})
// README
mockFetch.mockResolvedValueOnce({ ok: false })
// Icon paths — all fail
for (let i = 0; i < 6; i++) {
mockFetch.mockResolvedValueOnce({ ok: false })
}
// Releases
mockFetch.mockResolvedValueOnce({ ok: false })
// Raw URLs
for (let i = 0; i < 6; i++) {
mockFetch.mockResolvedValueOnce({ ok: false })
}
const result = await fetchGitHubAppInfo('https://github.com/bitcoin/bitcoin', 'bitcoin')
expect(result.description).toBe('Start9 wrapper')
})
it('handles fetch errors gracefully', async () => {
mockFetch.mockRejectedValue(new Error('Network error'))
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
expect(result).toEqual({})
})
})
describe('fetchMultipleAppInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
it('returns empty record for empty input', async () => {
const result = await fetchMultipleAppInfo([])
expect(result).toEqual({})
})
it('fetches info for multiple apps', async () => {
// Each app triggers multiple fetch calls, but they all return non-matching URLs
mockFetch.mockResolvedValue({ ok: false })
const apps = [
{ id: 'app1', 'wrapper-repo': 'not-a-github-url' },
{ id: 'app2', 'wrapper-repo': 'also-invalid' },
]
const result = await fetchMultipleAppInfo(apps)
expect(result.app1).toEqual({})
expect(result.app2).toEqual({})
})
})
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { shouldShowIntroSplash } from '../introSplash'
describe('shouldShowIntroSplash', () => {
it('skips intro on an already-onboarded node even without a browser intro flag', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/',
fromBoot: false,
onboardingComplete: true,
})).toBe(false)
})
it('shows intro for a fresh root visit when onboarding is not complete', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/',
fromBoot: false,
onboardingComplete: false,
})).toBe(true)
})
it('does not interrupt direct routes', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/dashboard/web5',
fromBoot: false,
onboardingComplete: null,
})).toBe(false)
})
it('an explicit replay request overrides every suppression rule', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: true,
replayRequested: true,
})).toBe(true)
})
it('a confirmed-fresh node plays the intro despite a stale per-origin seenIntro flag (reinstall / DHCP-recycled IP)', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: false,
})).toBe(true)
})
it('a confirmed-fresh node plays the intro on the boot-screen handoff too', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/login',
fromBoot: true,
onboardingComplete: false,
})).toBe(true)
})
it('stale seenIntro still suppresses when the backend answer is unknown', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: null,
})).toBe(false)
})
it('fresh node on a deep route without boot handoff stays suppressed', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/onboarding/seed',
fromBoot: false,
onboardingComplete: false,
})).toBe(false)
})
it('boot dev mode never root-boots into the intro', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/',
fromBoot: false,
devMode: 'boot',
onboardingComplete: false,
})).toBe(false)
})
})
+43
View File
@@ -0,0 +1,43 @@
const STORAGE_KEY = 'archipelago-app-usage'
export interface AppUsageEntry {
count: number
lastLaunchedAt: number
}
export type AppUsageMap = Record<string, AppUsageEntry>
function readUsage(): AppUsageMap {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as AppUsageMap
if (!parsed || typeof parsed !== 'object') return {}
return parsed
} catch {
return {}
}
}
function writeUsage(usage: AppUsageMap) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(usage))
} catch {
// Ignore unavailable or full localStorage.
}
}
export function recordAppLaunch(appId: string, now = Date.now()) {
if (!appId) return
const usage = readUsage()
const current = usage[appId]
usage[appId] = {
count: (current?.count || 0) + 1,
lastLaunchedAt: now,
}
writeUsage(usage)
}
export function getAppUsage(): AppUsageMap {
return readUsage()
}
+49
View File
@@ -0,0 +1,49 @@
// Machine-readable reason codes the backend embeds as a `[CODE]` token in
// receive-address errors (see core .../api/rpc/lnd/wallet.rs). Mapping the code
// directly is precise — unlike the substring heuristics below, it cannot
// mislabel an unreachable-REST failure as "wallet is locked" (the .228 bug).
const RECEIVE_CODE_MESSAGES: Record<string, string> = {
LND_REST_UNREACHABLE:
'Bitcoin address is not ready yet because the Lightning wallet service is still starting up or recovering. Please try again in a moment.',
LND_WALLET_LOCKED:
'Bitcoin address is not ready because the Lightning wallet is locked. Unlock or initialize LND first.',
LND_WALLET_UNINITIALIZED:
'Bitcoin address is not ready because the Lightning wallet has not been set up yet. Finish wallet setup, then try again.',
LND_SYNCING:
'Bitcoin address is not ready while the wallet is still syncing with the Bitcoin network. Try again once sync has progressed.',
LND_ERROR:
'Bitcoin address is not ready yet. Check that the Lightning app is healthy, then try again.',
}
export function explainReceiveAddressFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error || '')
// Prefer the structured reason code when present.
const code = message.match(/\[([A-Z_]+)\]/)?.[1]
if (code && RECEIVE_CODE_MESSAGES[code]) {
return RECEIVE_CODE_MESSAGES[code]
}
const lower = message.toLowerCase()
if (lower.includes('wallet') && (lower.includes('locked') || lower.includes('unlock'))) {
return 'Bitcoin address is not ready because the Lightning wallet is locked. Unlock or initialize LND first.'
}
if (lower.includes('uninitialized') || lower.includes('not initialized') || lower.includes('initwallet')) {
return 'Bitcoin address is not ready because the Lightning wallet has not been initialized yet.'
}
if (lower.includes('sync') || lower.includes('chain backend') || lower.includes('neutrino')) {
return 'Bitcoin address is not ready while Bitcoin or LND is still syncing. Try again once sync has progressed.'
}
if (lower.includes('rest connection failed') || lower.includes('failed to parse newaddress response')) {
return 'Bitcoin address is not ready because LND is not responding cleanly yet. Check that the Lightning app is healthy and retry.'
}
if (lower.includes('connection') || lower.includes('connect') || lower.includes('unavailable') || lower.includes('refused')) {
return 'Bitcoin address is not ready because LND is not reachable yet. Check that the Lightning app is running.'
}
if (lower.includes('did not return') || lower.includes('empty address')) {
return 'Bitcoin address is not ready because LND did not return an address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync.'
}
return message || 'Bitcoin address is not ready yet. Check Bitcoin and LND status, then try again.'
}
+511
View File
@@ -0,0 +1,511 @@
// Dummy apps data for first launch
// This can be easily replaced with real package data later
// Similar to how atob and k484 are handled
import type { PackageDataEntry } from '../types/api'
import { PackageState, ServiceStatus } from '../types/api'
export const dummyApps: Record<string, PackageDataEntry> = {
'bitcoin': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'Bitcoin Core node for the Neode network',
icon: '/assets/img/app-icons/bitcoin.svg'
},
manifest: {
id: 'bitcoin',
title: 'Bitcoin Core',
version: '24.0.0',
description: {
short: 'Full Bitcoin node implementation',
long: 'Bitcoin Core is the reference implementation of Bitcoin. It provides a full node implementation that validates and relays transactions, maintains the blockchain, and serves as a wallet.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': 'https://github.com/bitcoin/bitcoin',
'upstream-repo': 'https://github.com/bitcoin/bitcoin',
'support-site': 'https://github.com/bitcoin/bitcoin/issues',
'marketing-site': 'https://bitcoin.org',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'bitcoin.onion',
'lan-address': 'http://localhost:18443'
}
},
status: ServiceStatus.Running
}
},
'btcpay-server': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'BTCPay Server payment processor',
icon: '/assets/img/app-icons/btcpay-server.png'
},
manifest: {
id: 'btcpay-server',
title: 'BTCPay Server',
version: '1.12.0',
description: {
short: 'Self-hosted Bitcoin payment processor',
long: 'BTCPay Server is a free, open-source cryptocurrency payment processor. Accept Bitcoin payments without intermediaries or fees. Complete merchant solution with invoicing, point of sale, and more.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': 'https://github.com/btcpayserver/btcpayserver',
'upstream-repo': 'https://github.com/btcpayserver/btcpayserver',
'support-site': 'https://github.com/btcpayserver/btcpayserver/issues',
'marketing-site': 'https://btcpayserver.org',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'btcpay.onion',
'lan-address': 'http://localhost:14142'
}
},
status: ServiceStatus.Running
}
},
'homeassistant': {
state: PackageState.Running,
'static-files': {
license: 'Apache-2.0',
instructions: 'Home automation platform',
icon: '/assets/img/app-icons/homeassistant.png'
},
manifest: {
id: 'homeassistant',
title: 'Home Assistant',
version: '2024.1.0',
description: {
short: 'Open source home automation platform',
long: 'Home Assistant is an open-source home automation platform running on Python. It tracks and controls all devices at home and offers a platform for automating control.'
},
'release-notes': 'Initial release',
license: 'Apache-2.0',
'wrapper-repo': 'https://github.com/home-assistant/core',
'upstream-repo': 'https://github.com/home-assistant/core',
'support-site': 'https://github.com/home-assistant/core/issues',
'marketing-site': 'https://www.home-assistant.io',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'homeassistant.onion',
'lan-address': 'http://localhost:8123'
}
},
status: ServiceStatus.Running
}
},
'lorabell': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'A LoRa based doorbell',
icon: '/assets/img/app-icons/lorabell.png'
},
manifest: {
id: 'lorabell',
title: 'LoraBell',
version: '1.0.0',
description: {
short: 'A LoRa based doorbell',
long: 'A LoRa based doorbell - receive doorbell notifications over LoRa radio.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': '#',
'upstream-repo': '#',
'support-site': '#',
'marketing-site': '#',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'lorabell.onion',
'lan-address': 'http://192.168.1.166'
}
},
status: ServiceStatus.Running
}
},
'grafana': {
state: PackageState.Running,
'static-files': {
license: 'Apache-2.0',
instructions: 'Analytics and monitoring platform',
icon: '/assets/img/grafana.png'
},
manifest: {
id: 'grafana',
title: 'Grafana',
version: '10.2.0',
description: {
short: 'Analytics and monitoring platform',
long: 'Grafana is an open-source analytics and monitoring platform. Create dashboards, query metrics, and visualize data from multiple sources.'
},
'release-notes': 'Initial release',
license: 'Apache-2.0',
'wrapper-repo': 'https://github.com/grafana/grafana',
'upstream-repo': 'https://github.com/grafana/grafana',
'support-site': 'https://github.com/grafana/grafana/issues',
'marketing-site': 'https://grafana.com',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'grafana.onion',
'lan-address': 'http://localhost:3000'
}
},
status: ServiceStatus.Running
}
},
'endurain': {
state: PackageState.Stopped,
'static-files': {
license: 'MIT',
instructions: 'Endurain application',
icon: '/assets/img/endurain.png'
},
manifest: {
id: 'endurain',
title: 'Endurain',
version: '1.0.0',
description: {
short: 'Endurain application platform',
long: 'Endurain provides a platform for decentralized applications and services.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': 'https://github.com/endurain/endurain',
'upstream-repo': 'https://github.com/endurain/endurain',
'support-site': 'https://github.com/endurain/endurain/issues',
'marketing-site': 'https://endurain.io',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'endurain.onion',
'lan-address': 'http://localhost:8084'
}
},
status: ServiceStatus.Stopped
}
},
'fedimint': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'Federated Bitcoin mint',
icon: '/assets/img/app-icons/fedimint.png'
},
manifest: {
id: 'fedimint',
title: 'Fedimint',
version: '0.3.0',
description: {
short: 'Federated Bitcoin minting service',
long: 'Fedimint is a federated Bitcoin mint that enables private, scalable Bitcoin transactions through a federation of guardians.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': 'https://github.com/fedimint/fedimint',
'upstream-repo': 'https://github.com/fedimint/fedimint',
'support-site': 'https://github.com/fedimint/fedimint/issues',
'marketing-site': 'https://fedimint.org',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'fedimint.onion',
'lan-address': 'http://localhost:8175'
}
},
status: ServiceStatus.Running
}
},
'morphos-server': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'MorphOS server application',
icon: '/assets/img/morphos.png'
},
manifest: {
id: 'morphos-server',
title: 'MorphOS Server',
version: '1.0.0',
description: {
short: 'MorphOS server platform',
long: 'MorphOS Server provides a flexible server platform for various applications and services.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': 'https://github.com/morphos/morphos',
'upstream-repo': 'https://github.com/morphos/morphos',
'support-site': 'https://github.com/morphos/morphos/issues',
'marketing-site': 'https://morphos.io',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'morphos.onion',
'lan-address': 'http://localhost:8081'
}
},
status: ServiceStatus.Running
}
},
'lightning-stack': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'Lightning Network stack',
icon: '/assets/img/app-icons/lightning-stack.png'
},
manifest: {
id: 'lightning-stack',
title: 'Lightning Stack',
version: '0.12.0',
description: {
short: 'Complete Lightning Network implementation',
long: 'Lightning Stack provides a complete Lightning Network node implementation with LND, CLN, and supporting services for fast Bitcoin transactions.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': 'https://github.com/lightningnetwork/lnd',
'upstream-repo': 'https://github.com/lightningnetwork/lnd',
'support-site': 'https://github.com/lightningnetwork/lnd/issues',
'marketing-site': 'https://lightning.network',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'lightning.onion',
'lan-address': 'http://localhost:8080'
}
},
status: ServiceStatus.Running
}
},
'mempool': {
state: PackageState.Running,
'static-files': {
license: 'AGPL-3.0',
instructions: 'Bitcoin mempool explorer',
icon: '/assets/img/app-icons/mempool.png'
},
manifest: {
id: 'mempool',
title: 'Mempool',
version: '2.5.0',
description: {
short: 'Bitcoin mempool and blockchain explorer',
long: 'Mempool is an open-source Bitcoin mempool and blockchain explorer. View transactions, blocks, and network statistics in real-time.'
},
'release-notes': 'Initial release',
license: 'AGPL-3.0',
'wrapper-repo': 'https://github.com/mempool/mempool',
'upstream-repo': 'https://github.com/mempool/mempool',
'support-site': 'https://github.com/mempool/mempool/issues',
'marketing-site': 'https://mempool.space',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'mempool.onion',
'lan-address': 'http://localhost:4080'
}
},
status: ServiceStatus.Running
}
},
'ollama': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'Local AI model runner',
icon: '/assets/img/app-icons/ollama.png'
},
manifest: {
id: 'ollama',
title: 'Ollama',
version: '0.1.0',
description: {
short: 'Run large language models locally',
long: 'Ollama allows you to run large language models locally on your Neode server. Download and run models like Llama, Mistral, and more without cloud dependencies.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': 'https://github.com/ollama/ollama',
'upstream-repo': 'https://github.com/ollama/ollama',
'support-site': 'https://github.com/ollama/ollama/issues',
'marketing-site': 'https://ollama.ai',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'ollama.onion',
'lan-address': 'http://localhost:11434'
}
},
status: ServiceStatus.Running
}
},
'searxng': {
state: PackageState.Running,
'static-files': {
license: 'AGPL-3.0',
instructions: 'Privacy-respecting search engine',
icon: '/assets/img/app-icons/searxng.png'
},
manifest: {
id: 'searxng',
title: 'SearXNG',
version: '2024.1.0',
description: {
short: 'Privacy-respecting metasearch engine',
long: 'SearXNG is a privacy-respecting, hackable metasearch engine. Aggregate results from multiple search engines without tracking or ads.'
},
'release-notes': 'Initial release',
license: 'AGPL-3.0',
'wrapper-repo': 'https://github.com/searxng/searxng',
'upstream-repo': 'https://github.com/searxng/searxng',
'support-site': 'https://github.com/searxng/searxng/issues',
'marketing-site': 'https://searxng.org',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'searxng.onion',
'lan-address': 'http://localhost:8082'
}
},
status: ServiceStatus.Running
}
},
'indeedhub': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'Decentralized media streaming platform',
icon: '/assets/img/app-icons/indeedhub.png'
},
manifest: {
id: 'indeedhub',
title: 'Indeehub',
version: '0.1.0',
description: {
short: 'Decentralized media streaming platform',
long: 'Indeehub is a decentralized media streaming platform built on Nostr. Stream Bitcoin-focused documentaries, educational content, and independent films. Netflix-inspired interface with glassmorphism design, supporting content creators through the decentralized web.'
},
'release-notes': 'Initial release with Netflix-inspired interface',
license: 'MIT',
'wrapper-repo': 'https://github.com/indeedhub/indeedhub',
'upstream-repo': 'https://github.com/indeedhub/indeedhub',
'support-site': 'https://github.com/indeedhub/indeedhub/issues',
'marketing-site': 'https://indeedhub.com',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': '',
'lan-address': 'http://localhost:7778'
}
},
status: ServiceStatus.Running
}
},
'botfights': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'AI bot arena',
icon: '/assets/img/app-icons/botfights.svg'
},
manifest: {
id: 'botfights',
title: 'BotFights',
version: '1.0.0',
description: {
short: 'AI bot arena — build, train, and battle autonomous agents',
long: 'BotFights is an AI bot arena where you can build, train, and battle autonomous agents. Create intelligent bots using various strategies, pit them against other players\' creations, and climb the leaderboard. Features real-time battle visualization, multiple game modes, and a growing community of bot builders.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': 'https://botfights.net',
website: 'https://botfights.net',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: { 'tor-address': '', 'lan-address': 'http://localhost:9100' }
},
status: ServiceStatus.Running
}
},
}
+182
View File
@@ -0,0 +1,182 @@
// Utility to fetch app information from GitHub repositories
// Used to get icons, descriptions, and other metadata for dummy apps
export interface GitHubAppInfo {
icon?: string
description?: string
readme?: string
homepage?: string
}
/**
* Fetch app information from GitHub repository
* @param repoUrl GitHub repository URL (e.g., https://github.com/start9labs/bitcoin)
* @param appId App ID to help find the correct repository
*/
export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promise<GitHubAppInfo> {
try {
// Extract owner and repo from URL
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/)
if (!match) {
if (import.meta.env.DEV) console.warn(`[GitHub] Invalid repo URL: ${repoUrl}`)
return {}
}
const [, owner, repo] = match
// Try to find Start9 wrapper repo first (e.g., bitcoin-startos)
const start9RepoName = `${appId}-startos`
let targetOwner = owner
let targetRepo = repo
// If the repo URL doesn't match the expected pattern, try Start9Labs
if (repo && !repo.includes('startos') && !repo.includes('start9')) {
// Try Start9Labs wrapper repo
try {
const start9RepoUrl = `https://api.github.com/repos/Start9Labs/${start9RepoName}`
const start9Response = await fetch(start9RepoUrl)
if (start9Response.ok) {
targetOwner = 'Start9Labs'
targetRepo = start9RepoName
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Start9 repo lookup failed, falling back to original repo', e)
}
}
// Fetch repository info
const repoApiUrl = `https://api.github.com/repos/${targetOwner}/${targetRepo}`
const repoResponse = await fetch(repoApiUrl)
if (!repoResponse.ok) {
if (import.meta.env.DEV) console.warn(`[GitHub] Failed to fetch repo ${targetOwner}/${targetRepo}: ${repoResponse.status}`)
return {}
}
const repoData = await repoResponse.json()
// Fetch README
let readme = ''
try {
const readmeResponse = await fetch(`https://api.github.com/repos/${targetOwner}/${targetRepo}/readme`)
if (readmeResponse.ok) {
const readmeData = await readmeResponse.json()
readme = atob(readmeData.content) // Base64 decode
}
} catch (e) {
if (import.meta.env.DEV) console.warn(`[GitHub] Failed to fetch README for ${targetOwner}/${targetRepo}`)
}
// Try to find icon in repository
// Common locations: icon.png, icon.svg, assets/icon.png, etc.
let icon: string | undefined
const iconPaths = [
'icon.png',
'icon.svg',
'assets/icon.png',
'assets/icon.svg',
'icon/icon.png',
'icon/icon.svg'
]
for (const iconPath of iconPaths) {
try {
const iconResponse = await fetch(`https://api.github.com/repos/${targetOwner}/${targetRepo}/contents/${iconPath}`)
if (iconResponse.ok) {
const iconData = await iconResponse.json()
if (iconData.download_url) {
icon = iconData.download_url
break
}
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Icon path lookup failed, trying next path', e)
}
}
// If no icon found, try to get from releases/assets
if (!icon) {
try {
const releasesResponse = await fetch(`https://api.github.com/repos/${targetOwner}/${targetRepo}/releases/latest`)
if (releasesResponse.ok) {
const releasesData = await releasesResponse.json()
const asset = releasesData.assets?.find((a: { name: string; browser_download_url: string }) =>
a.name.includes('icon') || a.name.includes('logo')
)
if (asset) {
icon = asset.browser_download_url
}
}
} catch (e) {
if (import.meta.env.DEV) console.warn('No icon from releases', e)
}
}
// If still no icon, try raw GitHub content URLs for common icon names
if (!icon) {
const rawIconPaths = [
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/main/icon.png`,
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/main/icon.svg`,
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/master/icon.png`,
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/master/icon.svg`,
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/main/assets/icon.png`,
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/main/assets/icon.svg`,
]
// Test each URL
for (const iconUrl of rawIconPaths) {
try {
const testResponse = await fetch(iconUrl, { method: 'HEAD' })
if (testResponse.ok) {
icon = iconUrl
break
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Raw icon URL failed, trying next URL', e)
}
}
}
return {
icon,
description: repoData.description || '',
readme,
homepage: repoData.homepage || repoData.html_url
}
} catch (error) {
if (import.meta.env.DEV) console.error(`[GitHub] Error fetching app info for ${repoUrl}:`, error)
return {}
}
}
/**
* Batch fetch app info for multiple apps
*/
export async function fetchMultipleAppInfo(
apps: Array<{ id: string; 'wrapper-repo': string }>
): Promise<Record<string, GitHubAppInfo>> {
const results: Record<string, GitHubAppInfo> = {}
// Fetch in parallel with rate limiting (max 5 concurrent)
const batchSize = 5
for (let i = 0; i < apps.length; i += batchSize) {
const batch = apps.slice(i, i + batchSize)
const batchPromises = batch.map(async (app) => {
const info = await fetchGitHubAppInfo(app['wrapper-repo'], app.id)
return { id: app.id, info }
})
const batchResults = await Promise.all(batchPromises)
batchResults.forEach(({ id, info }) => {
results[id] = info
})
// Rate limit: wait 1 second between batches
if (i + batchSize < apps.length) {
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
return results
}
+115
View File
@@ -0,0 +1,115 @@
// Client-side image compression presets for the mesh chat attachment picker,
// mirroring Columba's ImageCompressionPreset (Low/Medium/High/Original) —
// resize + iteratively-quality-reduced JPEG, entirely in the browser so no
// extra round-trip is needed before the existing send pipeline takes over.
export interface ImageCompressionPreset {
key: 'low' | 'medium' | 'high' | 'original'
displayName: string
description: string
maxDimensionPx: number
targetBytes: number
initialQuality: number
minQuality: number
}
export const IMAGE_COMPRESSION_PRESETS: ImageCompressionPreset[] = [
{
key: 'low',
displayName: 'Low',
description: '32KB max — best for LoRa',
maxDimensionPx: 320,
targetBytes: 32 * 1024,
initialQuality: 60,
minQuality: 30,
},
{
key: 'medium',
displayName: 'Medium',
description: '128KB max — balanced',
maxDimensionPx: 800,
targetBytes: 128 * 1024,
initialQuality: 75,
minQuality: 40,
},
{
key: 'high',
displayName: 'High',
description: '512KB max — good quality',
maxDimensionPx: 2048,
targetBytes: 512 * 1024,
initialQuality: 90,
minQuality: 50,
},
{
key: 'original',
displayName: 'Original',
description: 'No compression',
maxDimensionPx: Infinity,
targetBytes: Infinity,
initialQuality: 100,
minQuality: 100,
},
]
/** Resize + iteratively shrink JPEG quality until under the preset's target size
* (or `minQuality` is reached, whichever comes first). `original` is a no-op. */
export async function compressImage(file: File, preset: ImageCompressionPreset): Promise<File> {
if (preset.key === 'original') return file
const bitmap = await createImageBitmap(file)
const { width, height } = scaledDimensions(bitmap.width, bitmap.height, preset.maxDimensionPx)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas 2D context unavailable')
ctx.drawImage(bitmap, 0, 0, width, height)
bitmap.close()
let quality = preset.initialQuality / 100
let blob = await canvasToJpegBlob(canvas, quality)
while (blob.size > preset.targetBytes && quality > preset.minQuality / 100) {
quality = Math.max(quality - 0.1, preset.minQuality / 100)
blob = await canvasToJpegBlob(canvas, quality)
}
const name = file.name.replace(/\.[^./\\]+$/, '') + '.jpg'
return new File([blob], name, { type: 'image/jpeg', lastModified: Date.now() })
}
/** Tiny low-res JPEG (default 64px / low quality) for the `thumb_bytes` field
* on a ContentRef message — shown immediately on receipt, before the full
* image is fetched. */
export async function makeThumbnail(file: File, maxDimensionPx = 64, quality = 0.4): Promise<Uint8Array> {
const bitmap = await createImageBitmap(file)
const { width, height } = scaledDimensions(bitmap.width, bitmap.height, maxDimensionPx)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas 2D context unavailable')
ctx.drawImage(bitmap, 0, 0, width, height)
bitmap.close()
const blob = await canvasToJpegBlob(canvas, quality)
return new Uint8Array(await blob.arrayBuffer())
}
function scaledDimensions(width: number, height: number, maxDimensionPx: number): { width: number; height: number } {
if (!Number.isFinite(maxDimensionPx) || (width <= maxDimensionPx && height <= maxDimensionPx)) {
return { width, height }
}
const scale = maxDimensionPx / Math.max(width, height)
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) }
}
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('canvas.toBlob failed'))),
'image/jpeg',
quality,
)
})
}
+29
View File
@@ -0,0 +1,29 @@
export interface IntroSplashDecisionInput {
seenIntro: boolean
routePath: string
fromBoot: boolean
devMode?: string
onboardingComplete: boolean | null
/** Explicit "Replay intro" click — overrides every suppression rule. */
replayRequested?: boolean
}
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
if (input.replayRequested) return true
const isDirectRoute = input.routePath !== '/'
// A node the backend CONFIRMS has never completed onboarding always gets
// the full intro on a root boot. `seenIntro` is per-origin browser state —
// after a reinstall (or a DHCP-recycled IP), the browser still carries the
// previous node's flag at the same origin, which silently muted the intro
// on genuinely fresh installs.
if (input.onboardingComplete === false && (input.fromBoot || (!isDirectRoute && input.devMode !== 'boot'))) {
return true
}
if (input.seenIntro) return false
if (input.onboardingComplete === true) return false
if (input.fromBoot) return true
if (input.devMode === 'boot') return false
return !isDirectRoute
}
+128
View File
@@ -0,0 +1,128 @@
/**
* LoRa regional frequency plans — the canonical region list the backend's
* Meshtastic driver can program (core/.../mesh/meshtastic.rs region table),
* annotated with band / duty-cycle / power constraints from the Meshtastic
* firmware `regions[]` table so the UI can surface legality hints.
*
* Also used to derive suggested radio parameters for MeshCore and RNode
* presets (those firmwares take raw frequency/BW/SF/CR, not a region enum).
*/
export interface LoraRegion {
/** Wire value for mesh.configure { lora_region } */
code: string
label: string
/** Frequency range, MHz (display) */
band: string
/** Regional duty-cycle cap, percent (100 = none) */
dutyCyclePct: number
/** Max TX power, dBm */
maxPowerDbm: number
}
export const LORA_REGIONS: LoraRegion[] = [
{ code: 'US', label: 'United States / Americas (915 MHz)', band: '902928', dutyCyclePct: 100, maxPowerDbm: 30 },
{ code: 'EU_868', label: 'Europe (868 MHz)', band: '869.4869.65', dutyCyclePct: 10, maxPowerDbm: 27 },
{ code: 'EU_433', label: 'Europe (433 MHz)', band: '433434', dutyCyclePct: 10, maxPowerDbm: 10 },
{ code: 'ANZ', label: 'Australia / New Zealand (915 MHz)', band: '915928', dutyCyclePct: 100, maxPowerDbm: 30 },
{ code: 'ANZ_433', label: 'Australia / New Zealand (433 MHz)', band: '433.05434.79', dutyCyclePct: 100, maxPowerDbm: 14 },
{ code: 'NZ_865', label: 'New Zealand (865 MHz)', band: '864868', dutyCyclePct: 100, maxPowerDbm: 36 },
{ code: 'CN', label: 'China (470 MHz)', band: '470510', dutyCyclePct: 100, maxPowerDbm: 19 },
{ code: 'JP', label: 'Japan (920 MHz)', band: '920.5923.5', dutyCyclePct: 100, maxPowerDbm: 13 },
{ code: 'KR', label: 'South Korea (920 MHz)', band: '920923', dutyCyclePct: 100, maxPowerDbm: 23 },
{ code: 'TW', label: 'Taiwan (920 MHz)', band: '920925', dutyCyclePct: 100, maxPowerDbm: 27 },
{ code: 'RU', label: 'Russia (868 MHz)', band: '868.7869.2', dutyCyclePct: 100, maxPowerDbm: 20 },
{ code: 'IN', label: 'India (865 MHz)', band: '865867', dutyCyclePct: 100, maxPowerDbm: 30 },
{ code: 'TH', label: 'Thailand (920 MHz)', band: '920925', dutyCyclePct: 10, maxPowerDbm: 27 },
{ code: 'UA_868', label: 'Ukraine (868 MHz)', band: '868868.6', dutyCyclePct: 1, maxPowerDbm: 14 },
{ code: 'UA_433', label: 'Ukraine (433 MHz)', band: '433434.7', dutyCyclePct: 10, maxPowerDbm: 10 },
{ code: 'MY_919', label: 'Malaysia (919 MHz)', band: '919924', dutyCyclePct: 100, maxPowerDbm: 27 },
{ code: 'MY_433', label: 'Malaysia (433 MHz)', band: '433435', dutyCyclePct: 100, maxPowerDbm: 20 },
{ code: 'SG_923', label: 'Singapore (923 MHz)', band: '917925', dutyCyclePct: 100, maxPowerDbm: 20 },
{ code: 'PH_915', label: 'Philippines (915 MHz)', band: '915918', dutyCyclePct: 100, maxPowerDbm: 24 },
{ code: 'PH_868', label: 'Philippines (868 MHz)', band: '868869.4', dutyCyclePct: 100, maxPowerDbm: 14 },
{ code: 'PH_433', label: 'Philippines (433 MHz)', band: '433434.7', dutyCyclePct: 100, maxPowerDbm: 10 },
{ code: 'LORA_24', label: 'Worldwide (2.4 GHz, SX1280 only)', band: '24002483.5', dutyCyclePct: 100, maxPowerDbm: 10 },
]
export function regionByCode(code: string | null | undefined): LoraRegion | undefined {
if (!code) return undefined
return LORA_REGIONS.find(r => r.code === code.toUpperCase())
}
/**
* Coarse lat/lon → LoRa region suggestion. Bounding boxes, most-specific
* first — good enough to preselect the dropdown; the user confirms.
*/
export function suggestRegionFromLatLon(lat: number, lon: number): LoraRegion | undefined {
const boxes: Array<{ code: string; latMin: number; latMax: number; lonMin: number; lonMax: number }> = [
{ code: 'JP', latMin: 24, latMax: 46, lonMin: 123, lonMax: 146 },
{ code: 'KR', latMin: 33, latMax: 39, lonMin: 124, lonMax: 132 },
{ code: 'TW', latMin: 21.5, latMax: 25.5, lonMin: 119.5, lonMax: 122.5 },
{ code: 'PH_915', latMin: 4, latMax: 21, lonMin: 116, lonMax: 127 },
{ code: 'SG_923', latMin: 1, latMax: 1.6, lonMin: 103.5, lonMax: 104.2 },
{ code: 'MY_919', latMin: 0.8, latMax: 7.5, lonMin: 99, lonMax: 119.5 },
{ code: 'TH', latMin: 5.5, latMax: 20.5, lonMin: 97, lonMax: 106 },
{ code: 'IN', latMin: 6, latMax: 36, lonMin: 68, lonMax: 97.5 },
{ code: 'CN', latMin: 18, latMax: 54, lonMin: 73, lonMax: 135 },
{ code: 'UA_868', latMin: 44, latMax: 52.5, lonMin: 22, lonMax: 40.5 },
{ code: 'RU', latMin: 41, latMax: 82, lonMin: 27, lonMax: 180 },
{ code: 'ANZ', latMin: -48, latMax: -9, lonMin: 112, lonMax: 180 },
{ code: 'EU_868', latMin: 34, latMax: 72, lonMin: -25, lonMax: 45 },
// Americas
{ code: 'US', latMin: -56, latMax: 72, lonMin: -170, lonMax: -30 },
]
for (const b of boxes) {
if (lat >= b.latMin && lat <= b.latMax && lon >= b.lonMin && lon <= b.lonMax) {
return regionByCode(b.code)
}
}
return undefined
}
/** Community MeshCore radio plan for a region, where one is well established.
* MeshCore has no region enum — the firmware takes raw freq/BW/SF/CR — so
* these are display-level suggestions (EU is the verified community default;
* other regions show the legal band and defer to the local community plan). */
export interface MeshcorePlan {
freqMhz: number
bwKhz: number
sf: number
cr: number
}
export const MESHCORE_PLANS: Record<string, MeshcorePlan> = {
EU_868: { freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 },
EU_433: { freqMhz: 433.65, bwKhz: 250, sf: 11, cr: 5 },
}
export function meshcorePlanFor(code: string | null | undefined): MeshcorePlan | undefined {
if (!code) return undefined
return MESHCORE_PLANS[code.toUpperCase()]
}
/** A named MeshCore RF preset for the Device-panel dropdown. */
export interface MeshcoreRfPreset {
id: string
label: string
freqMhz: number
bwKhz: number
sf: number
cr: number
}
/**
* Prepopulated MeshCore RF plans by country/region for the Device panel's
* preset dropdown ("Custom" in the UI unlocks free entry). Values are only
* added here when verifiable: the MeshCore community plans above, the
* MeshCore firmware's own build defaults (platformio.ini `arduino_base`:
* 869.618/62.5/SF8, CR default 5; companion example: 915.0/250/SF10/CR5),
* and operator-attested deployment plans.
*/
export const MESHCORE_RF_PRESETS: MeshcoreRfPreset[] = [
{ id: 'eu_868', label: 'Europe / UK — 869.525 MHz, 250 kHz, SF11, CR4/5', freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 },
{ id: 'pt', label: 'Portugal — 869.618 MHz, 62.5 kHz, SF8, CR4/8', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 8 },
{ id: 'fw_default', label: 'MeshCore firmware default — 869.618 MHz, 62.5 kHz, SF8, CR4/5', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 5 },
{ id: 'us_anz_915', label: 'US / Canada / ANZ — 915.0 MHz, 250 kHz, SF10, CR4/5', freqMhz: 915.0, bwKhz: 250, sf: 10, cr: 5 },
{ id: 'eu_433', label: 'Europe (433 MHz) — 433.65 MHz, 250 kHz, SF11, CR4/5', freqMhz: 433.65, bwKhz: 250, sf: 11, cr: 5 },
]
+90
View File
@@ -0,0 +1,90 @@
/**
* Best-effort mapping from a detected serial port's USB identity to a board
* image (SVGs vendored from the Meshtastic web-flasher, GPL-3.0 —
* github.com/meshtastic/web-flasher, public/img/devices/).
*
* Native-USB boards (T-Deck, RAK4631, T1000-E…) report their name in the USB
* product string, so those match precisely. UART-bridge boards only identify
* the bridge chip (CP2102/CH340), so vid:pid picks the most common board for
* that chip and the label stays honest ("LoRa radio").
*/
export interface DetectedDeviceInfo {
path: string
vid?: string | null
pid?: string | null
product?: string | null
manufacturer?: string | null
}
export interface MeshDeviceImage {
/** Path under public/ */
image: string
/** Human name shown in the modal */
label: string
/** True when the match is exact (product string), false for chip-level guesses */
exact: boolean
}
const IMG = '/assets/img/mesh-devices'
/** product-string keyword → image (checked in order, case-insensitive) */
const PRODUCT_MATCHES: Array<{ match: RegExp; image: string; label: string }> = [
{ match: /t-?deck/i, image: `${IMG}/t-deck.svg`, label: 'LILYGO T-Deck' },
{ match: /t-?echo\s*plus/i, image: `${IMG}/t-echo_plus.svg`, label: 'LILYGO T-Echo Plus' },
{ match: /t-?echo/i, image: `${IMG}/t-echo.svg`, label: 'LILYGO T-Echo' },
{ match: /t-?beam\s*s3/i, image: `${IMG}/tbeam-s3-core.svg`, label: 'LILYGO T-Beam S3' },
{ match: /t-?beam/i, image: `${IMG}/tbeam.svg`, label: 'LILYGO T-Beam' },
{ match: /t3.?s3/i, image: `${IMG}/tlora-t3s3-v1.svg`, label: 'LILYGO T3-S3' },
{ match: /t-?lora|tlora/i, image: `${IMG}/tlora-v2-1-1_6.svg`, label: 'LILYGO T-LoRa' },
{ match: /rak.?4631|wisblock/i, image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631' },
{ match: /wismesh|wistap/i, image: `${IMG}/rak-wismeshtap.svg`, label: 'RAK WisMesh Tap' },
{ match: /t1000|tracker.?t1000/i, image: `${IMG}/tracker-t1000-e.svg`, label: 'Seeed Card Tracker T1000-E' },
{ match: /xiao/i, image: `${IMG}/seeed-xiao-s3.svg`, label: 'Seeed XIAO S3' },
{ match: /wio.?tracker|wm1110/i, image: `${IMG}/wio-tracker-wm1110.svg`, label: 'Seeed Wio Tracker' },
{ match: /station\s*g2/i, image: `${IMG}/station-g2.svg`, label: 'B&Q Station G2' },
{ match: /nano\s*g2/i, image: `${IMG}/nano-g2-ultra.svg`, label: 'B&Q Nano G2 Ultra' },
{ match: /t114/i, image: `${IMG}/heltec-mesh-node-t114.svg`, label: 'Heltec Mesh Node T114' },
{ match: /wireless\s*stick|wsl/i, image: `${IMG}/heltec-wsl-v3.svg`, label: 'Heltec Wireless Stick Lite V3' },
{ match: /heltec.*v4/i, image: `${IMG}/heltec_v4.svg`, label: 'Heltec V4' },
{ match: /heltec/i, image: `${IMG}/heltec-v3.svg`, label: 'Heltec LoRa 32 V3' },
{ match: /thinknode/i, image: `${IMG}/thinknode_m1.svg`, label: 'Elecrow ThinkNode' },
{ match: /pico/i, image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico' },
{ match: /rnode/i, image: `${IMG}/diy.svg`, label: 'RNode' },
]
/** vid:pid → most-likely board (bridge chips = chip-level guess) */
const VIDPID_MATCHES: Record<string, { image: string; label: string; exact: boolean }> = {
// Silicon Labs CP210x — Heltec V3 family ships this bridge
'10c4:ea60': { image: `${IMG}/heltec-v3.svg`, label: 'LoRa radio (CP2102 serial)', exact: false },
// WCH CH340 — most T-Beam / T-LoRa boards
'1a86:7523': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH340 serial)', exact: false },
'1a86:55d4': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH9102 serial)', exact: false },
// Espressif native USB (S3 boards: T3-S3, T-Deck without product str)
'303a:1001': { image: `${IMG}/tlora-t3s3-v1.svg`, label: 'ESP32-S3 LoRa board', exact: false },
// RAK4631 nRF52 native USB
'239a:8029': { image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631', exact: true },
// Raspberry Pi (Pico W)
'2e8a:0005': { image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico', exact: false },
}
const FALLBACK: MeshDeviceImage = {
image: `${IMG}/unknown-new.svg`,
label: 'LoRa mesh radio',
exact: false,
}
export function resolveMeshDeviceImage(info: DetectedDeviceInfo | undefined): MeshDeviceImage {
if (!info) return FALLBACK
const product = `${info.manufacturer ?? ''} ${info.product ?? ''}`.trim()
if (product) {
for (const m of PRODUCT_MATCHES) {
if (m.match.test(product)) return { image: m.image, label: m.label, exact: true }
}
}
if (info.vid && info.pid) {
const hit = VIDPID_MATCHES[`${info.vid}:${info.pid}`.toLowerCase()]
if (hit) return hit
}
return FALLBACK
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Open a URL in the device's real browser.
*
* In a normal mobile/desktop browser this is just `window.open(_blank)`. Inside
* the Android companion app the page runs in a WebView where `window.open` is
* unreliable (noopener/noreferrer can suppress onCreateWindow), so the native
* shell injects a `window.ArchipelagoNative.openExternal(url)` bridge that hands
* the URL to an ACTION_VIEW intent. We prefer the bridge when present and fall
* back to `window.open` otherwise — so the working mobile-browser path is
* untouched.
*/
interface ArchipelagoNativeBridge {
openExternal?: (url: string) => void
openInApp?: (url: string) => void
}
function nativeBridge(): ArchipelagoNativeBridge | undefined {
return (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge }).ArchipelagoNative
}
/**
* True when running inside the Android companion app (native WebView shell).
* The shell injects `window.ArchipelagoNative`; a plain mobile browser / PWA
* never has it.
*/
export function isCompanionApp(): boolean {
const native = nativeBridge()
return !!native && typeof native.openInApp === 'function'
}
export function openExternalUrl(url: string): void {
if (!url) return
const native = nativeBridge()
if (native && typeof native.openExternal === 'function') {
native.openExternal(url)
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
/**
* Launch an app that can't be embedded in an iframe (X-Frame-Options) from a
* mobile surface — with NO "this app opens in a tab" interstitial.
*
* - Android companion: hand it to the in-app WebView (`openInApp`) so it stays
* inside Archipelago with the native back/forward/reload/close controls.
* - Plain mobile browser (PWA): open directly in a new browser tab.
*/
export function openInAppOrNewTab(url: string): void {
if (!url) return
const native = nativeBridge()
if (native && typeof native.openInApp === 'function') {
native.openInApp(url)
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
+19
View File
@@ -0,0 +1,19 @@
/** Video Picture-in-Picture helpers (Chromium/Safari; no-ops elsewhere).
* Kiosk note: PiP is skipped on the WM-less kiosk X session by callers that
* care — an unmanaged popup there is unusable (docs/tv-input-iframe-apps.md
* sibling investigation, task #18). */
export const pipSupported =
typeof document !== 'undefined' &&
'pictureInPictureEnabled' in document &&
document.pictureInPictureEnabled
export async function togglePip(video: HTMLVideoElement | null | undefined): Promise<void> {
if (!video || !pipSupported) return
try {
if (document.pictureInPictureElement === video) await document.exitPictureInPicture()
else await video.requestPictureInPicture()
} catch {
// Permission/transient failure — the button is best-effort.
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Format a version string for display with exactly one leading "v".
*
* Version strings reach the UI from several sources — manifests (bare like
* "1.7.96"), node/federation state and the update RPC (sometimes already
* "v1.7.96"). Templates used to hard-code a `v` prefix (`v{{ version }}`),
* which produced "vv1.7.96" whenever the source already carried a "v". This
* normalizes both shapes to a single "v".
*/
export function displayVersion(v?: string | null): string {
if (v === null || v === undefined) return ''
const bare = String(v).trim().replace(/^v+/i, '')
return bare ? `v${bare}` : ''
}
// Exposed globally as `$ver` (see main.ts) so templates can normalize version
// labels without a per-file import.
declare module 'vue' {
interface ComponentCustomProperties {
$ver: (v?: string | null) => string
}
}