Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66e4bac6d | ||
|
|
af2dfd0bd6 | ||
|
|
68c25534d4 | ||
|
|
299f7d8f39 | ||
|
|
bb231e82b4 | ||
|
|
e2309cc2ac | ||
|
|
a48499daeb | ||
|
|
6df9afe684 | ||
|
|
9d21dd5111 | ||
|
|
2e5f67a59a |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 17
|
||||
versionName = "0.4.13"
|
||||
versionCode = 18
|
||||
versionName = "0.4.14"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -239,7 +239,12 @@ private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
// CameraX's analysis default is 640x480 — too few pixels per module
|
||||
// to decode a modal-sized QR at arm's length. 1280x720 more than
|
||||
// doubles the pixel density at negligible analysis cost.
|
||||
@Suppress("DEPRECATION")
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setTargetResolution(android.util.Size(1280, 720))
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also {
|
||||
@@ -268,7 +273,14 @@ private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
|
||||
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
private val reader = MultiFormatReader().apply {
|
||||
setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
|
||||
setHints(
|
||||
mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
// Screen-displayed QRs come with moiré, glare, and soft focus at
|
||||
// close range — the exhaustive search is worth the milliseconds.
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
@@ -284,7 +296,13 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
|
||||
0, 0, image.width, image.height,
|
||||
false,
|
||||
)
|
||||
val result = reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||
val result = try {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||
} catch (_: NotFoundException) {
|
||||
// Dark-themed pages can render light-on-dark QRs — retry inverted.
|
||||
reader.reset()
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
|
||||
}
|
||||
onDecoded(result.text)
|
||||
} catch (_: NotFoundException) {
|
||||
// No QR in this frame — keep scanning.
|
||||
|
||||
@@ -2032,6 +2032,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
about: 'Self-sovereign Bitcoin node',
|
||||
nip05: 'satoshi@archipelago.local',
|
||||
lud16: 'satoshi@getalby.com',
|
||||
picture: '/demo-avatars/satoshi.svg',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2044,7 +2045,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
is_default: false,
|
||||
nostr_pubkey: 'f6e5d4c3b2a19876543210fedcba9876543210fedcba9876543210fedcba98',
|
||||
nostr_npub: 'npub1mockanonidentitypubkeyvalue000000000000000000000000000xyz',
|
||||
profile: { display_name: 'Anon' },
|
||||
profile: { display_name: 'Anon', picture: '/demo-avatars/anon.svg' },
|
||||
},
|
||||
{
|
||||
id: 'id-merchant',
|
||||
@@ -2056,7 +2057,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
is_default: false,
|
||||
nostr_pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
nostr_npub: 'npub1mockmerchantidentitypubkeyvalue000000000000000000000000def',
|
||||
profile: { display_name: 'My Shop', website: 'https://myshop.onion' },
|
||||
profile: { display_name: 'My Shop', website: 'https://myshop.onion', picture: '/demo-avatars/business.svg' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2079,6 +2080,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
profile: {
|
||||
display_name: 'Archipelago Node',
|
||||
about: 'Self-sovereign Bitcoin node',
|
||||
picture: '/demo-avatars/satoshi.svg',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2121,6 +2123,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
about: 'Running a sovereign Bitcoin node',
|
||||
nip05: 'satoshi@archipelago.local',
|
||||
lud16: 'satoshi@getalby.com',
|
||||
picture: '/demo-avatars/satoshi.svg',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2136,6 +2139,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
profile: {
|
||||
display_name: 'Archy Consulting',
|
||||
about: 'Bitcoin infrastructure services',
|
||||
picture: '/demo-avatars/business.svg',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2146,7 +2150,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi5hjrZo4HzmQnwzaxWhAbWAs',
|
||||
created_at: '2026-03-01T18:00:00Z',
|
||||
is_default: false,
|
||||
profile: {},
|
||||
profile: { picture: '/demo-avatars/anon.svg' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2163,7 +2167,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||
nostr_npub: 'npub1598eg0y7m08htfzjfmzv6zjvf5u7p00dr9w0yfamaxqhkwlryckq5dh9ee',
|
||||
is_default: true, created_at: '2026-01-10T08:00:00Z',
|
||||
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node' },
|
||||
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node', picture: '/demo-avatars/satoshi.svg' },
|
||||
},
|
||||
}
|
||||
return res.json({ result: identities[id] || identities['id-primary'] })
|
||||
@@ -2235,12 +2239,40 @@ app.post('/rpc/v1', (req, res) => {
|
||||
bytes_out: 642_889_310,
|
||||
} })
|
||||
}
|
||||
// Fake tunnel provisioning so the companion remote-access onboarding
|
||||
// (CompanionIntroOverlay) walks the same steps as a real node. The keys
|
||||
// are not real; importing this config yields a harmless dead tunnel.
|
||||
case 'vpn.list-peers': {
|
||||
return res.json({ result: { peers: mockState.vpnPeers || [] } })
|
||||
}
|
||||
case 'vpn.create-peer':
|
||||
case 'vpn.peer-config': {
|
||||
const peerName = params?.name || 'device'
|
||||
if (!mockState.vpnPeers) mockState.vpnPeers = []
|
||||
if (!mockState.vpnPeers.some((p) => p.name === peerName)) {
|
||||
mockState.vpnPeers.push({ name: peerName, ip: '10.44.0.7' })
|
||||
}
|
||||
const config = [
|
||||
'[Interface]',
|
||||
'PrivateKey = 2DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem0=',
|
||||
'Address = 10.44.0.7/32',
|
||||
'DNS = 1.1.1.1',
|
||||
'',
|
||||
'[Peer]',
|
||||
'PublicKey = 3DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem1=',
|
||||
'Endpoint = demo.archipelago-foundation.org:51820',
|
||||
'AllowedIPs = 10.44.0.0/16',
|
||||
'PersistentKeepalive = 25',
|
||||
].join('\n')
|
||||
return res.json({ result: { config, peer_ip: '10.44.0.7' } })
|
||||
}
|
||||
|
||||
// Node visibility — interactive (persisted per demo session)
|
||||
case 'network.get-visibility': {
|
||||
// No onion_address in the demo: the public showcase shouldn't display
|
||||
// a Tor address (even a fake one) on the Node Visibility card.
|
||||
return res.json({ result: {
|
||||
visibility: mockState.nodeVisibility,
|
||||
onion_address: mockData['server-info']['tor-address'],
|
||||
} })
|
||||
}
|
||||
case 'network.set-visibility': {
|
||||
@@ -2248,7 +2280,6 @@ app.post('/rpc/v1', (req, res) => {
|
||||
if (['hidden', 'discoverable', 'public'].includes(v)) mockState.nodeVisibility = v
|
||||
return res.json({ result: {
|
||||
visibility: mockState.nodeVisibility,
|
||||
onion_address: mockData['server-info']['tor-address'],
|
||||
} })
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#7e22ce;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#231133;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#d8b4fe;stop-opacity:0.5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="240" fill="url(#bg)"/>
|
||||
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- hooded figure, face in shadow -->
|
||||
<path d="M120 44 C86 44 72 76 72 106 L72 132 L168 132 L168 106 C168 76 154 44 120 44 Z" fill="url(#fig)"/>
|
||||
<ellipse cx="120" cy="104" rx="30" ry="34" fill="rgba(35,17,51,0.9)"/>
|
||||
<path d="M45 240 C45 182 78 150 120 150 C162 150 195 182 195 240 Z" fill="url(#fig)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1000 B |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#c2540a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b1c0a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.85" />
|
||||
<stop offset="100%" style="stop-color:#fdba74;stop-opacity:0.55" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="240" fill="url(#bg)"/>
|
||||
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- head + suited shoulders -->
|
||||
<circle cx="120" cy="92" r="38" fill="url(#fig)"/>
|
||||
<path d="M45 240 C45 178 78 152 120 152 C162 152 195 178 195 240 Z" fill="url(#fig)"/>
|
||||
<!-- collar + tie -->
|
||||
<path d="M104 156 L120 176 L136 156 L120 148 Z" fill="rgba(59,28,10,0.85)"/>
|
||||
<path d="M115 176 L125 176 L123 214 L120 220 L117 214 Z" fill="rgba(59,28,10,0.85)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.85" />
|
||||
<stop offset="100%" style="stop-color:#93c5fd;stop-opacity:0.55" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="240" fill="url(#bg)"/>
|
||||
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- head + shoulders -->
|
||||
<circle cx="120" cy="92" r="38" fill="url(#fig)"/>
|
||||
<path d="M45 240 C45 178 78 152 120 152 C162 152 195 178 195 240 Z" fill="url(#fig)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 860 B |
Binary file not shown.
Binary file not shown.
@@ -59,19 +59,137 @@
|
||||
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
Download app
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showPairScreen"
|
||||
@click="advanceFromDownload"
|
||||
>
|
||||
I've installed it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screen 2 (remote access): install WireGuard -->
|
||||
<div v-else-if="step === 'wireguard'" key="wireguard">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.5 12l1.8 1.8L14.8 10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Install WireGuard for remote access</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
WireGuard gives your phone a private tunnel to this node, so the companion app works away from home too.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex justify-center mb-4">
|
||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="wgApkQrDataUrl"
|
||||
:src="wgApkQrDataUrl"
|
||||
alt="WireGuard app download QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
:href="wireguardDownloadUrl"
|
||||
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
Download WireGuard
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showWgQrScreen"
|
||||
>
|
||||
I've installed it
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full mt-2 py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showDownloadScreen"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Screen 3 (remote access): scan the tunnel config in WireGuard -->
|
||||
<div v-else-if="step === 'wgqr'" key="wgqr">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v8m-4-4h8" />
|
||||
<rect x="3.5" y="3.5" width="17" height="17" rx="4" stroke-width="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Connect the tunnel</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
In WireGuard, tap <strong>+</strong>, scan this code, then switch the new tunnel on.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-3">
|
||||
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="wgQrDataUrl"
|
||||
:src="wgQrDataUrl"
|
||||
alt="WireGuard tunnel config QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5 flex items-center justify-center">
|
||||
<svg v-if="wgLoading" class="w-6 h-6 animate-spin text-white/40" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
|
||||
|
||||
<!-- Same-device path: a phone can't scan its own screen, so offer
|
||||
the config as a file WireGuard can import. -->
|
||||
<button
|
||||
v-if="wgConfig"
|
||||
type="button"
|
||||
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-2"
|
||||
@click="downloadWgConfig"
|
||||
>
|
||||
Download tunnel config
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors mb-2"
|
||||
@click="showPairScreen"
|
||||
>
|
||||
I'm connected
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showWireguardScreen('back')"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Screen 2: pair the app with this node -->
|
||||
<div v-else key="pair">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
@@ -92,7 +210,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<!-- Bigger than the download QR: this one is scanned by the
|
||||
companion app camera, and physical size drives decode. -->
|
||||
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="pairQrDataUrl"
|
||||
:src="pairQrDataUrl"
|
||||
@@ -116,7 +236,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showDownloadScreen"
|
||||
@click="backFromPair"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
@@ -132,6 +252,7 @@
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
||||
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
@@ -150,13 +271,43 @@ const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
||||
const PAIR_SCHEME = 'archipelago://pair'
|
||||
const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org'
|
||||
|
||||
// Remote-access onboarding: inserts the WireGuard install + tunnel-QR steps
|
||||
// between "I've installed it" and the pairing QR. Real nodes pair against the
|
||||
// node's VPN address instead of the LAN origin; the demo walks the same steps
|
||||
// with a mock tunnel config (its pairing QR still carries the public demo
|
||||
// URL). Set to false to restore the original two-screen flow (instant revert).
|
||||
const REMOTE_ACCESS_STEPS = true
|
||||
|
||||
// The WireGuard peer config only routes the VPN subnet (AllowedIPs
|
||||
// 10.44.0.0/16 — core/archipelago/src/api/rpc/vpn.rs), so away from the LAN
|
||||
// the node is reachable exclusively at its tunnel address. The pairing QR
|
||||
// must therefore advertise this, not the browser's LAN origin. The flow has
|
||||
// the user switch the tunnel on right before pairing, so it validates.
|
||||
const VPN_NODE_URL = 'http://10.44.0.1'
|
||||
|
||||
// Official WireGuard Android app (com.wireguard.android 1.0.20260315,
|
||||
// sha256 f92971bc…, mirrored from download.wireguard.com), served like the
|
||||
// companion APK: gitea raw-on-main for nodes, own origin for the demo.
|
||||
const WIREGUARD_DOWNLOAD_URL = IS_DEMO
|
||||
? `${window.location.origin}/packages/wireguard.apk`
|
||||
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/wireguard.apk'
|
||||
|
||||
// Name of the auto-created VPN peer for the phone running the companion.
|
||||
const WG_PEER_NAME = 'companion-phone'
|
||||
|
||||
const visible = ref(false)
|
||||
const step = ref<'download' | 'pair'>('download')
|
||||
const step = ref<'download' | 'wireguard' | 'wgqr' | 'pair'>('download')
|
||||
const slideName = ref('slide-forward')
|
||||
const qrDataUrl = ref('')
|
||||
const pairQrDataUrl = ref('')
|
||||
const pairingUrl = ref('')
|
||||
const wgApkQrDataUrl = ref('')
|
||||
const wgQrDataUrl = ref('')
|
||||
const wgConfig = ref('')
|
||||
const wgLoading = ref(false)
|
||||
const wgError = ref('')
|
||||
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
|
||||
const wireguardDownloadUrl = import.meta.env.VITE_WIREGUARD_APK_URL || WIREGUARD_DOWNLOAD_URL
|
||||
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
@@ -202,17 +353,43 @@ function maybeShow() {
|
||||
}, 250)
|
||||
}
|
||||
|
||||
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
|
||||
watch(companionIntroRequested, (requested) => {
|
||||
if (!requested) return
|
||||
companionIntroRequested.value = false
|
||||
if (calmTicker) {
|
||||
clearInterval(calmTicker)
|
||||
calmTicker = null
|
||||
}
|
||||
step.value = 'download'
|
||||
visible.value = true
|
||||
})
|
||||
|
||||
watch(visible, async (isVisible) => {
|
||||
if (!isVisible) return
|
||||
// Generate large and let CSS scale down — at 112px source a ~45-module QR
|
||||
// is 2.5px/module, which camera decoders (the companion app included)
|
||||
// routinely fail on. 512px keeps every module crisp.
|
||||
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
|
||||
width: 112,
|
||||
margin: 1,
|
||||
width: 512,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
if (REMOTE_ACCESS_STEPS && !wgApkQrDataUrl.value) {
|
||||
wgApkQrDataUrl.value = await QRCode.toDataURL(wireguardDownloadUrl, {
|
||||
width: 512,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
}
|
||||
}, { immediate: true, flush: 'post' })
|
||||
|
||||
/**
|
||||
@@ -223,6 +400,9 @@ watch(visible, async (isVisible) => {
|
||||
*/
|
||||
async function resolveServerUrl(): Promise<string> {
|
||||
if (IS_DEMO) return DEMO_SERVER_URL
|
||||
// Remote-access flow: the app must connect through the tunnel the user just
|
||||
// switched on — the LAN origin is unreachable from outside AllowedIPs.
|
||||
if (REMOTE_ACCESS_STEPS) return VPN_NODE_URL
|
||||
const { hostname, origin } = window.location
|
||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
|
||||
try {
|
||||
@@ -247,9 +427,11 @@ async function showPairScreen() {
|
||||
step.value = 'pair'
|
||||
if (!pairQrDataUrl.value) {
|
||||
pairingUrl.value = await buildPairingUrl()
|
||||
// Large source + a real quiet zone; this QR is scanned by the companion
|
||||
// app's camera, so give it every advantage (see download QR note above).
|
||||
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
|
||||
width: 112,
|
||||
margin: 1,
|
||||
width: 512,
|
||||
margin: 3,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
@@ -264,6 +446,77 @@ function showDownloadScreen() {
|
||||
step.value = 'download'
|
||||
}
|
||||
|
||||
function advanceFromDownload() {
|
||||
if (REMOTE_ACCESS_STEPS) showWireguardScreen('forward')
|
||||
else void showPairScreen()
|
||||
}
|
||||
|
||||
function showWireguardScreen(direction: 'forward' | 'back' = 'forward') {
|
||||
slideName.value = direction === 'forward' ? 'slide-forward' : 'slide-back'
|
||||
step.value = 'wireguard'
|
||||
}
|
||||
|
||||
function showWgQrScreen() {
|
||||
slideName.value = 'slide-forward'
|
||||
step.value = 'wgqr'
|
||||
void loadWgPeer()
|
||||
}
|
||||
|
||||
function backFromPair() {
|
||||
if (REMOTE_ACCESS_STEPS) {
|
||||
slideName.value = 'slide-back'
|
||||
step.value = 'wgqr'
|
||||
} else {
|
||||
showDownloadScreen()
|
||||
}
|
||||
}
|
||||
|
||||
// Create (or fetch) the phone's VPN peer and render its config as a QR.
|
||||
// Reuses the same RPCs as the Server page's Add Device modal; the peer is
|
||||
// looked up first so reopening the modal never duplicates it.
|
||||
async function loadWgPeer() {
|
||||
if (wgQrDataUrl.value || wgLoading.value) return
|
||||
wgLoading.value = true
|
||||
wgError.value = ''
|
||||
try {
|
||||
const listed = await rpcClient
|
||||
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
|
||||
.catch(() => ({ peers: [] as { name: string }[] }))
|
||||
const exists = (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
|
||||
const res = await rpcClient.call<{ config: string; peer_ip: string }>({
|
||||
method: exists ? 'vpn.peer-config' : 'vpn.create-peer',
|
||||
params: { name: WG_PEER_NAME },
|
||||
})
|
||||
wgConfig.value = res.config
|
||||
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
|
||||
width: 512,
|
||||
margin: 3,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config'
|
||||
} finally {
|
||||
wgLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Same-device path: hand the config to the WireGuard app as an importable
|
||||
// .conf file (a phone can't scan its own screen).
|
||||
function downloadWgConfig() {
|
||||
if (!wgConfig.value) return
|
||||
const blob = new Blob([wgConfig.value], { type: 'application/octet-stream' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'archipelago.conf'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
visible.value = false
|
||||
step.value = 'download'
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Cross-view trigger for the Remote Companion intro/pairing modal
|
||||
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
|
||||
* App Store banner call openCompanionIntro() to pop it on demand — this
|
||||
* bypasses the once-per-browser auto-show gate.
|
||||
*/
|
||||
export const companionIntroRequested = ref(false)
|
||||
|
||||
export function openCompanionIntro(): void {
|
||||
companionIntroRequested.value = true
|
||||
}
|
||||
@@ -150,6 +150,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile companion app banner — opens the download/pairing modal -->
|
||||
<CompanionBanner />
|
||||
|
||||
<!-- Category Section Divider -->
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<span class="discover-terminal-tag">all</span>
|
||||
@@ -239,6 +242,7 @@ import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout
|
||||
import { APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import DiscoverHero from './discover/DiscoverHero.vue'
|
||||
import FeaturedApps from './discover/FeaturedApps.vue'
|
||||
import CompanionBanner from './discover/CompanionBanner.vue'
|
||||
import AppGrid from './discover/AppGrid.vue'
|
||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<!-- Companion app banner — same format as the featured app banner, with a
|
||||
phone mockup rising out of the right edge. Clicking anywhere (or the
|
||||
Install button) opens the Remote Companion download/pairing modal. -->
|
||||
<div
|
||||
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||
@click="openCompanionIntro()"
|
||||
>
|
||||
<img
|
||||
src="/assets/img/bg-intro-4.webp"
|
||||
alt=""
|
||||
class="featured-banner-img"
|
||||
@error="(e: Event) => ((e.target as HTMLImageElement).style.display = 'none')"
|
||||
/>
|
||||
<div class="featured-banner-overlay companion-banner-overlay">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="discover-terminal-tag">companion</span>
|
||||
<span class="text-white/50 text-sm font-mono">REMOTE CONTROL // IN YOUR POCKET</span>
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">Your Node. In Your Pocket.</h2>
|
||||
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">
|
||||
The Archipelago Companion puts your node on your phone — remote control and typing,
|
||||
your cloud files on the go, and one scan to connect. Sovereignty doesn't stay home.
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click.stop="openCompanionIntro()"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
|
||||
>Install</button>
|
||||
<span class="text-white/40 text-sm">Archipelago Companion · Android</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="companion-phone-wrap" aria-hidden="true">
|
||||
<div class="companion-phone">
|
||||
<span class="companion-phone-lens"></span>
|
||||
<img src="/assets/img/companion-phone-screen.webp" alt="" class="companion-phone-screen" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { openCompanionIntro } from '@/composables/useCompanionIntro'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.companion-banner {
|
||||
border-color: rgba(251, 146, 60, 0.25);
|
||||
}
|
||||
|
||||
/* Keep the copy clear of the phone mockup on wide screens */
|
||||
@media (min-width: 768px) {
|
||||
.companion-banner-overlay {
|
||||
padding-right: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.companion-phone-wrap {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.companion-phone-wrap {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 3.5rem;
|
||||
z-index: 2;
|
||||
transform: rotate(6deg);
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.companion-banner:hover .companion-phone-wrap {
|
||||
transform: rotate(4deg) translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
.companion-phone {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
padding: 9px;
|
||||
border-radius: 30px;
|
||||
background: #0b0b12;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow:
|
||||
0 24px 60px rgba(0, 0, 0, 0.55),
|
||||
0 0 46px rgba(251, 146, 60, 0.28),
|
||||
inset 0 0 2px rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Punch-hole camera */
|
||||
.companion-phone-lens {
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 9999px;
|
||||
background: #000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.companion-phone-screen {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
/* Screen glare */
|
||||
.companion-phone::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 9px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(115deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 28%, transparent 45%);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -368,8 +368,10 @@ onMounted(() => load())
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<!-- mb-0 overrides the pill's default mb-4, which pushed it above the
|
||||
title's centerline in this inline header row. -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<BackButton @click="goBack" />
|
||||
<BackButton desktop-margin="mb-0" @click="goBack" />
|
||||
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,21 +11,6 @@
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
|
||||
</div>
|
||||
<div class="web5-card-actions-top gap-2 shrink-0">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ t('web5.findNodes') }}
|
||||
</button>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: stacked layout -->
|
||||
<div class="md:hidden mb-4">
|
||||
@@ -174,7 +159,9 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-4 space-y-3">
|
||||
<div class="web5-card-actions-bottom-grid grid-cols-2 gap-3">
|
||||
<!-- Always the bottom 50/50 pair, every screen size — consistent with
|
||||
the other web5 cards' full-width bottom actions. -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
@@ -182,37 +169,13 @@
|
||||
{{ t('web5.findNodes') }}
|
||||
</button>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
@click="refreshActiveTab"
|
||||
:disabled="loadingPeers || loadingRequests"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
{{ loadingPeers || loadingRequests ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="nodesContainerTab === 'trusted'"
|
||||
@click="discoverAndAddPeers"
|
||||
:disabled="discovering"
|
||||
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="nodesContainerTab === 'observers'"
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="loadConnectionRequests"
|
||||
:disabled="loadingRequests"
|
||||
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -328,7 +291,6 @@ const observers = ref<Peer[]>(cached.observers ?? [])
|
||||
const loadingPeers = ref(false)
|
||||
const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {})
|
||||
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
|
||||
const discovering = ref(false)
|
||||
|
||||
// Send message modal
|
||||
const showSendMessageModal = ref(false)
|
||||
@@ -378,6 +340,12 @@ function switchToRequestsTab() {
|
||||
}
|
||||
}
|
||||
|
||||
// The single bottom Refresh acts on whichever tab is open.
|
||||
function refreshActiveTab() {
|
||||
if (nodesContainerTab.value === 'requests') void loadConnectionRequests()
|
||||
else void loadPeers()
|
||||
}
|
||||
|
||||
async function loadPeers() {
|
||||
const hadPeers = peers.value.length > 0 || observers.value.length > 0
|
||||
loadingPeers.value = true
|
||||
@@ -456,28 +424,6 @@ async function sendMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverAndAddPeers() {
|
||||
discovering.value = true
|
||||
try {
|
||||
const res = await rpcClient.discoverNodes()
|
||||
const nodes = res.nodes || []
|
||||
for (const n of nodes) {
|
||||
if (n.onion && n.pubkey) {
|
||||
try {
|
||||
await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey })
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Peer may already exist', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadPeers()
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Discover failed:', e)
|
||||
} finally {
|
||||
discovering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConnectionRequests() {
|
||||
const hadRequests = connectionRequests.value.length > 0
|
||||
loadingRequests.value = true
|
||||
|
||||
@@ -61,9 +61,91 @@
|
||||
<div
|
||||
v-for="(identity, idx) in managedIdentities"
|
||||
:key="identity.id"
|
||||
:class="{ 'card-stagger': showStagger }" class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg"
|
||||
:class="{ 'card-stagger': showStagger }"
|
||||
:style="{ '--stagger-index': idx }"
|
||||
>
|
||||
<!-- Narrow containers (phones): profile-card layout with banner +
|
||||
overlapping avatar. Hidden on wide containers where the classic
|
||||
row below renders instead. -->
|
||||
<div class="identity-card-m rounded-xl overflow-hidden bg-white/[0.08]">
|
||||
<div class="relative h-16" :class="bannerFallback(identity)">
|
||||
<img
|
||||
v-if="identity.profile?.banner"
|
||||
:src="displayableUrl(identity.profile.banner)"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 pb-4">
|
||||
<div class="flex items-end justify-between -mt-7">
|
||||
<button @click="openProfileEditor(identity)" class="relative w-14 h-14 rounded-full overflow-hidden ring-4 ring-black/60 group shrink-0" title="Edit profile">
|
||||
<img
|
||||
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
|
||||
:src="displayableUrl(identity.profile.picture)"
|
||||
class="w-full h-full object-cover"
|
||||
@error="() => { listPictureFailed[identity.id] = true }"
|
||||
/>
|
||||
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
|
||||
'bg-blue-500/20': identity.purpose === 'personal',
|
||||
'bg-orange-500/20': identity.purpose === 'business',
|
||||
'bg-purple-500/20': identity.purpose === 'anonymous',
|
||||
}">
|
||||
<span class="text-lg font-bold" :class="{
|
||||
'text-blue-400': identity.purpose === 'personal',
|
||||
'text-orange-400': identity.purpose === 'business',
|
||||
'text-purple-400': identity.purpose === 'anonymous',
|
||||
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
||||
</div>
|
||||
</button>
|
||||
<div class="flex items-center gap-1 pt-8">
|
||||
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDeleteIdentity(identity)" class="p-2 rounded-lg text-white/50 hover:text-red-400 hover:bg-white/10 transition-colors" title="Delete">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2 flex-wrap min-w-0">
|
||||
<span class="text-white font-semibold text-sm truncate">{{ identity.profile?.display_name || identity.name }}</span>
|
||||
<span v-if="identity.is_default" class="text-yellow-400 text-xs" title="Default identity">★</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
|
||||
'bg-blue-500/20 text-blue-300': identity.purpose === 'personal',
|
||||
'bg-orange-500/20 text-orange-300': identity.purpose === 'business',
|
||||
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
|
||||
}">{{ identity.purpose }}</span>
|
||||
</div>
|
||||
<p v-if="identity.profile?.about" class="text-white/60 text-xs mt-1 leading-relaxed">{{ identity.profile.about }}</p>
|
||||
<div class="mt-2 space-y-0.5">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
|
||||
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="identity.nostr_npub" class="flex items-center gap-1 min-w-0">
|
||||
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
|
||||
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wide containers: the original row — desktop appearance unchanged -->
|
||||
<div class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg">
|
||||
<div class="identity-row-main flex items-center gap-4 min-w-0">
|
||||
<!-- Avatar -->
|
||||
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
|
||||
@@ -134,6 +216,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -465,6 +548,16 @@ watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false
|
||||
// Rewrite onion-rooted `/blob/<cid>` URLs (with or without capability
|
||||
// query) to same-origin `/blob/<cid>` so they render in this UI. Data
|
||||
// URLs and plain external URLs pass through untouched.
|
||||
// Gradient banner backdrop for the narrow profile card when the identity has
|
||||
// no banner image, tinted by purpose to match the avatar/chip colors.
|
||||
function bannerFallback(identity: ManagedIdentity): string {
|
||||
switch (identity.purpose) {
|
||||
case 'business': return 'bg-gradient-to-br from-orange-500/40 to-amber-500/15'
|
||||
case 'anonymous': return 'bg-gradient-to-br from-purple-500/40 to-fuchsia-500/15'
|
||||
default: return 'bg-gradient-to-br from-blue-500/40 to-indigo-500/15'
|
||||
}
|
||||
}
|
||||
|
||||
function displayableUrl(url: string | null | undefined): string {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('data:') || url.startsWith('/')) return url
|
||||
@@ -710,8 +803,21 @@ defineExpose({ loadIdentities, managedIdentities })
|
||||
container-name: identities-card;
|
||||
}
|
||||
|
||||
/* Narrow containers (default): the profile card renders and the classic row
|
||||
is hidden. Wide containers: the original desktop row — untouched — and the
|
||||
profile card hides. Doubled selectors out-rank the Tailwind utilities on
|
||||
the same elements regardless of stylesheet order. */
|
||||
.identities-card .identity-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container identities-card (min-width: 560px) {
|
||||
.identity-row {
|
||||
.identities-card .identity-card-m {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.identities-card .identity-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
<template>
|
||||
<!-- Node Visibility -->
|
||||
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 3">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.nodeVisibility') }}</h2>
|
||||
<p class="text-white/70 text-sm">
|
||||
Make your node publicly discoverable. When enabled, anyone on the Nostr
|
||||
network can find your node and request a connection — requests always
|
||||
wait for your approval and join as a Peer, never trusted.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="visibilityLoading" class="shrink-0">
|
||||
<svg class="animate-spin h-5 w-5 text-white/40" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<div class="mb-4 shrink-0">
|
||||
<div class="flex items-center sm:items-start gap-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white sm:mb-2">{{ t('web5.nodeVisibility') }}</h2>
|
||||
<!-- Desktop: description beside the icon, as before -->
|
||||
<p class="hidden sm:block text-white/70 text-sm">
|
||||
Make your node publicly discoverable. When enabled, anyone on the Nostr
|
||||
network can find your node and request a connection — requests always
|
||||
wait for your approval and join as a Peer, never trusted.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="visibilityLoading" class="shrink-0">
|
||||
<svg class="animate-spin h-5 w-5 text-white/40" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: description stacked under the icon + title -->
|
||||
<p class="sm:hidden mt-3 text-white/70 text-sm">
|
||||
Make your node publicly discoverable. When enabled, anyone on the Nostr
|
||||
network can find your node and request a connection — requests always
|
||||
wait for your approval and join as a Peer, never trusted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Enable switch -->
|
||||
@@ -57,16 +66,7 @@
|
||||
|
||||
<!-- Discoverable nodes -->
|
||||
<div v-if="discoverEnabled" class="mt-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-medium text-white">Discoverable nodes</p>
|
||||
<button
|
||||
class="px-2.5 py-1 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="discovering"
|
||||
@click="discoverNodes"
|
||||
>
|
||||
{{ discovering ? 'Searching…' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-white mb-2">Discoverable nodes</p>
|
||||
<div v-if="discovering && discoveredNodes.length === 0" class="py-4 text-center text-white/45 text-xs">
|
||||
Searching relays…
|
||||
</div>
|
||||
@@ -95,10 +95,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<p v-if="discoverEnabled" class="mt-3 text-xs text-amber-400/80">
|
||||
{{ t('web5.discoverableWarning') }}
|
||||
</p>
|
||||
<!-- Refresh — full-width card action on every screen size -->
|
||||
<button
|
||||
v-if="discoverEnabled"
|
||||
class="mt-4 w-full glass-button rounded-lg py-2.5 text-sm font-medium text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="discovering"
|
||||
@click="discoverNodes"
|
||||
>
|
||||
{{ discovering ? 'Searching…' : 'Refresh' }}
|
||||
</button>
|
||||
|
||||
<PeerRequestModal
|
||||
:show="requestModalTarget !== null"
|
||||
|
||||
@@ -37,7 +37,9 @@ export default defineConfig({
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//],
|
||||
// /packages/ must bypass the SPA fallback — otherwise clicking the
|
||||
// companion APK download link gets index.html instead of the file.
|
||||
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//, /^\/packages\//],
|
||||
cleanupOutdatedCaches: true,
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
|
||||
globIgnores: [
|
||||
|
||||
Reference in New Issue
Block a user