Companion 0.5.28 — backup & restore (#128), NIP-46 remote signer (#139), companion-gated install pitch (#61 residual)

The companion-agent queue from the 2026-08-30 handoff, complete:

- Backup & Restore: hub sub-page, SAF export/import sealed in the
  node's ADR-005 envelope (Argon2id + ChaCha20-Poly1305, byte-compatible
  with core backup.rs), merge-only restore, no cloud.
- Remote Signer: the phone is the NIP-46 bunker — nsec generate/import,
  nostrconnect:// QR pairing (scanner + OS deep link), per-request
  approve/deny card, NIP-44 v2 transport with NIP-04 receive fallback,
  wire-faithful to rust-nostr's reference bunker. Crypto pinned to the
  official NIP-44 + BIP-340 vectors; e2e harness included.
- #61 residual: banner + manual intro trigger + overlay all gate on
  isCompanionApp() (web-side, vitest-covered).
- Hub modal: new sub-pages like Nodes/FIPS, 70% height cap, node ULA
  display/copy in the Nodes list, fipssh Termux helper (npub→ULA is a
  pure public-key function — verified against the fips crate).

Issues #61 (comment), #128, #139 closed on the tracker.
This commit is contained in:
Dorian
2026-08-31 19:34:05 +01:00
29 changed files with 4306 additions and 53 deletions
@@ -143,6 +143,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 { isCompanionApp } from '@/utils/openExternal'
import { useLoginTransitionStore } from '@/stores/loginTransition'
import { useServerStore } from '@/stores/server'
import { rpcClient } from '@/api/rpc-client'
@@ -205,10 +206,12 @@ const POST_INTRO_GRACE_MS = 2000
let calmTicker: ReturnType<typeof setInterval> | null = null
// Running inside the companion app's own WebView (it injects this JS bridge).
// Running inside the companion app's own WebView (it injects the JS bridge —
// detected with the canonical helper, not a raw window check, so the gate
// is identical everywhere the question is asked).
// The "get the companion app" pitch is nonsense there — the user is already in
// it. Server management for connected companions lives in the NESMenu instead.
const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined'
const IN_COMPANION_APP = isCompanionApp()
onMounted(() => {
if (IN_COMPANION_APP) return
@@ -247,9 +250,13 @@ function maybeShow() {
}
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
// The trigger itself is already a no-op inside the companion (useCompanionIntro),
// and this watcher refuses to open there too, so no caller can ever pop the
// install pitch inside the app it installs (#61).
watch(companionIntroRequested, (requested) => {
if (!requested) return
companionIntroRequested.value = false
if (IN_COMPANION_APP) return
if (calmTicker) {
clearInterval(calmTicker)
calmTicker = null
@@ -0,0 +1,41 @@
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
import { companionIntroRequested, openCompanionIntro } from '../useCompanionIntro'
// #61: the manual intro trigger (App Store banner etc.) must be a no-op inside
// the companion app's WebView — the "install the companion" pitch is nonsense
// where the user is already running it. The auto-popup was already gated
// (CompanionIntroOverlay.onMounted); openCompanionIntro is the second, manual
// path and the banner render (CompanionBanner) the third.
type TestWindow = Window & { ArchipelagoNative?: unknown }
const w = window as TestWindow
beforeEach(() => {
companionIntroRequested.value = false
})
afterEach(() => {
delete w.ArchipelagoNative
})
describe('openCompanionIntro', () => {
it('raises the manual intro request in a plain browser/PWA', () => {
expect(companionIntroRequested.value).toBe(false)
openCompanionIntro()
expect(companionIntroRequested.value).toBe(true)
})
it('is a no-op inside the companion app (bridge with openInApp)', () => {
w.ArchipelagoNative = { openInApp: () => {}, openExternal: () => {} }
openCompanionIntro()
expect(companionIntroRequested.value).toBe(false)
})
it('still fires when the bridge exists but is not the companion shell', () => {
// Partial bridge (no openInApp) is not the companion app — a future
// embedder must still see the pitch.
w.ArchipelagoNative = { openExternal: () => {} }
openCompanionIntro()
expect(companionIntroRequested.value).toBe(true)
})
})
@@ -1,13 +1,20 @@
import { ref } from 'vue'
import { isCompanionApp } from '@/utils/openExternal'
/**
* 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.
*
* Inside the companion app's own WebView the whole pitch is nonsense — the
* user is already running it — so the trigger is a no-op there (#61: the
* auto-popup was gated, this manual path and CompanionBanner weren't).
*/
export const companionIntroRequested = ref(false)
export function openCompanionIntro(): void {
if (isCompanionApp()) return
companionIntroRequested.value = true
}
@@ -1,8 +1,11 @@
<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. -->
Install button) opens the Remote Companion download/pairing modal.
Not rendered at all inside the companion app's WebView: pitching
"install the companion" to someone already in it is noise (#61). -->
<div
v-if="showPitch"
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="openCompanionIntro()"
>
@@ -41,7 +44,11 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { isCompanionApp } from '@/utils/openExternal'
import { openCompanionIntro } from '@/composables/useCompanionIntro'
const showPitch = computed(() => !isCompanionApp())
</script>
<style scoped>