orb-daemon 0.1.0

Orb DevKit desktop daemon - secure TCP/WS bridge for the Orb mobile app
<template>
  <div>
    <SplashScreen v-if="phase === 'splash'" @done="onSplashDone" />
    <template v-else>
      <Transition name="app-fade">
        <div v-if="appVisible" class="contents">
          <NuxtLayout><NuxtPage /></NuxtLayout>
        </div>
      </Transition>
      <IdleLockScreen />
      <PinLockScreen />
    </template>
  </div>
</template>

<script setup lang="ts">
import { ref, watch, onMounted, nextTick } from 'vue'
import SplashScreen   from '~/components/SplashScreen.vue'
import IdleLockScreen from '~/components/IdleLockScreen.vue'
import PinLockScreen  from '~/components/PinLockScreen.vue'
import { useDark }    from '~/composables/useDark'
import { initIdleLock } from '~/composables/useIdleLock'
import { settings }   from '~/composables/useStore'
import { pinEnabled, lockWithPin } from '~/composables/usePin'
import { initDatabase } from '~/utils/initDatabase'
import { useDaemon } from '~/composables/useDaemon'
import { reloadDevices } from '~/composables/useDevices'
import { App as CapApp } from '@capacitor/app'

type Phase = 'splash' | 'app'
const phase      = ref<Phase>('splash')
const appVisible = ref(false)

const { initDark } = useDark()
initDark()

// ── Accent color injection ─────────────────────────────────
let accentStyleEl: HTMLStyleElement | null = null

function buildAccentCSS(hex: string): string {
  const r = parseInt(hex.slice(1, 3), 16)
  const g = parseInt(hex.slice(3, 5), 16)
  const b = parseInt(hex.slice(5, 7), 16)
  return `
:root { --accent: ${hex}; --accent-r: ${r}; --accent-g: ${g}; --accent-b: ${b}; }
.bg-violet-50, .dark\\:bg-violet-950\\/40 { background-color: rgba(${r},${g},${b},0.08) !important; }
.bg-violet-100 { background-color: rgba(${r},${g},${b},0.15) !important; }
.bg-violet-400 { background-color: rgba(${r},${g},${b},0.75) !important; }
.bg-violet-500 { background-color: rgba(${r},${g},${b},1)    !important; }
.bg-violet-600 { background-color: rgba(${r},${g},${b},0.88) !important; }
.bg-violet-500\\/10 { background-color: rgba(${r},${g},${b},0.10) !important; }
.bg-violet-500\\/20 { background-color: rgba(${r},${g},${b},0.20) !important; }
.bg-violet-500\\/30 { background-color: rgba(${r},${g},${b},0.30) !important; }
.bg-violet-950\\/40 { background-color: rgba(${r},${g},${b},0.08) !important; }
.text-violet-300 { color: rgba(${r},${g},${b},0.75) !important; }
.text-violet-400 { color: rgba(${r},${g},${b},0.85) !important; }
.text-violet-500 { color: rgba(${r},${g},${b},1)    !important; }
.text-violet-600 { color: rgba(${r},${g},${b},0.88) !important; }
.border-violet-500       { border-color: rgba(${r},${g},${b},1)    !important; }
.border-violet-500\\/20  { border-color: rgba(${r},${g},${b},0.20) !important; }
.border-violet-500\\/25  { border-color: rgba(${r},${g},${b},0.25) !important; }
.ring-violet-400  { --tw-ring-color: rgba(${r},${g},${b},0.8) !important; }
.ring-violet-500  { --tw-ring-color: rgba(${r},${g},${b},1)   !important; }
.shadow-violet-500\\/25 { --tw-shadow-color: rgba(${r},${g},${b},0.25) !important; }
.shadow-violet-500\\/30 { --tw-shadow-color: rgba(${r},${g},${b},0.30) !important; }
.shadow-violet-500\\/40 { --tw-shadow-color: rgba(${r},${g},${b},0.40) !important; }
.accent-violet-500 { accent-color: rgba(${r},${g},${b},1) !important; }
.from-violet-500 { --tw-gradient-from: rgba(${r},${g},${b},1) !important; }
.to-violet-600   { --tw-gradient-to:   rgba(${r},${g},${b},0.85) !important; }
  `.trim()
}

function injectAccent(hex: string) {
  if (typeof document === 'undefined') return
  if (!accentStyleEl) {
    accentStyleEl = document.createElement('style')
    accentStyleEl.id = 'orb-accent'
    document.head.appendChild(accentStyleEl)
  }
  accentStyleEl.textContent = buildAccentCSS(hex)
}

injectAccent(settings.value.accentColor)
watch(() => settings.value.accentColor, hex => injectAccent(hex))

onMounted(async () => {
  try {
    const { SplashScreen } = await import('@capacitor/splash-screen')
    await SplashScreen.hide()
  } catch {}

  initDatabase().catch(e => console.warn('[Orb] DB init:', e))

  const { connect: daemonConnect, daemonInfo, status: daemonStatus } = useDaemon()

  // Reconnect when app comes to foreground.
  // We check daemonInfo (are we paired?) rather than status (which can be stale).
  CapApp.addListener('appStateChange', async ({ isActive }) => {
    if (isActive && daemonInfo.value) {
      // Small delay to let the network stack recover after backgrounding
      await new Promise(r => setTimeout(r, 800))
      // connect() internally checks isSocketAlive() so it's safe to call always
      daemonConnect().catch(() => {})
      // Sync device list state from localStorage
      reloadDevices()
    }
  })

  // Initial connect attempt on startup if we have saved pairing info
  if (daemonInfo.value) {
    daemonConnect().catch(() => {})
  }
})

async function onSplashDone() {
  await enterApp()
}

async function enterApp() {
  phase.value = 'app'
  initIdleLock()
  if (pinEnabled.value) lockWithPin()
  await nextTick()
  setTimeout(() => { appVisible.value = true }, 40)
}
</script>

<style>
.app-fade-enter-active {
  transition: opacity 0.55s cubic-bezier(0.4, 0, 0.2, 1),
              transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
}
.app-fade-enter-from {
  opacity: 0;
  transform: translateY(10px) scale(0.995);
}
.app-fade-enter-to {
  opacity: 1;
  transform: translateY(0) scale(1);
}
</style>